Skip to content

Commit

Events: review requests, assignments, stops and deployments are published

Work publishes pull.assigned, pull.review_requested and pull.review_request_removed from update_pull, issue.assigned now says who was newly added, pull.stalled the first time g1t stops on a pull request (with why, and who stopped it when a person did), and pull.resumed when it picks back up. Deployments publishes deployment.succeeded and deployment.failed for every finished build, saying whether a success follows a failure; it gets a service binding to g1t-events for that. Webhooks can be sent all seven, and workflows see the pull_request ones as assigned, review_requested and review_request_removed.

syntaqxcommitted Parente1017e9Browse files
15 files+254−70/15 viewed
+5−1
7575 | `repo.collaborator_added`, `repo.collaborator_role_changed`, `repo.collaborator_removed` | Someone was given a role on it, had their role changed, or lost it. `data.username`, `data.role`, `data.previous_role`. See [access and roles](/guides/access-and-roles/). |
7676 | `repo.archived`, `repo.unarchived` | It was made read-only, or writable again. |
7777 | `repo.deleted`, `repo.restored`, `repo.purged` | It was deleted, restored within its 30 days, or removed for good. |
78−| `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number` and `data.author` (`id` and `username`); on close, `data.reason` and `data.resolved_by`. For an issue g1t's agent filed while at work, `data.author` is g1t and `data.requested_by` is the person it was working for. |
78+| `issue.opened`, `issue.updated`, `issue.assigned`, `issue.closed`, `issue.reopened` | An issue changed. `data.number` and `data.author` (`id` and `username`); on close, `data.reason` and `data.resolved_by`; on assignment, `data.assignees` and the newly assigned `data.added`. For an issue g1t's agent filed while at work, `data.author` is g1t and `data.requested_by` is the person it was working for. |
7979 | `comment.created` | A comment or review on an issue or pull request. |
8080 | `pull.opened`, `pull.ready`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` | A pull request changed. `data.number`, `data.issue` and `data.author` (`id` and `username`); on merge, `data.commit`. For a change g1t made, `data.author` is g1t and `data.requested_by` is the person who asked for it; `actor` is still whoever caused the event. On a change by g1t, once g1t has worked it out, `data.confidence`: `level` (`high`, `medium` or `low`), `reasons`, `self_reported`, `uncertain_about`, `run_id` and `assessed_at`. See [how sure the agent is](/guides/working-with-g1t/#how-sure-the-agent-is). |
81+| `pull.assigned` | People were assigned to a pull request. `data.assignees` is everyone assigned now, `data.added` those newly assigned. |
82+| `pull.review_requested`, `pull.review_request_removed` | Reviewers were asked for a pull request, or no longer are. `data.reviewers` names them. |
83+| `pull.stalled`, `pull.resumed` | g1t stopped seeing a pull request through until a person steps in, with why in `data.detail`; or it picked back up. |
8184 | `checks.completed` | A pull request's checks finished: every status on its head has reported and none is still pending, or the merge queue took it out. `data.number`, `data.commit`, and `data.status`, `passed` or `failed`. |
8285 | `review.completed` | g1t reviewed a pull request. `data.verdict`. |
8386 | `workflow.completed` | A [workflow](/guides/actions/) run finished. `data.workflow`, `data.conclusion`, `data.run_id`, `data.sha`, `data.pull`. |
87+| `deployment.succeeded`, `deployment.failed` | A build of a [project](/guides/deployments/) finished, for production or a pull request's preview. `data.deployment_id`, `data.project`, `data.kind` (`production` or `preview`), `data.number` for a preview, `data.commit`, `data.path`, `data.error` on failure, and `data.recovered` when a success follows a failure. |
8488 | `queue.changed` | The merge queue gained, lost or settled an entry. |
8589 | `session.appended` | An agent's session grew. Busy: choose it only if you need it. |
8690 | `agent.asked` | An agent asked the agent on another pull request a question, or handed it work, while that one was not at work; g1t wakes it to answer. |
+3−0
1515 "pull.updated" => pull("synchronize"),
1616 "pull.ready" => pull("ready_for_review"),
1717 "pull.closed" | "pull.merged" => pull("closed"),
18+ "pull.assigned" => pull("assigned"),
19+ "pull.review_requested" => pull("review_requested"),
20+ "pull.review_request_removed" => pull("review_request_removed"),
1821 "issue.opened" => vec![("issues", Some("opened"))],
1922 "issue.updated" => vec![("issues", Some("edited"))],
2023 "issue.closed" => vec![("issues", Some("closed"))],
+49−1
9696 /// On `issue.assigned`: the people it is now assigned to.
9797 #[serde(skip_serializing_if = "Option::is_none")]
9898 pub assignees: Option<Vec<String>>,
99+ /// On `issue.assigned`: those of them who were not before.
100+ #[serde(skip_serializing_if = "Option::is_none")]
101+ pub added: Option<Vec<String>>,
99102 }
100103
101104 /// The payload of `pull.opened`, `pull.ready`, `pull.updated` (its head
102−/// moved), `pull.closed` and `pull.merged`; each uses the fields that apply to it.
105+/// moved), `pull.closed`, `pull.merged`, `pull.assigned`,
106+/// `pull.review_requested` and `pull.review_request_removed` (reviewers
107+/// asked, or no longer), `pull.stalled` (g1t stopped seeing it through
108+/// until a person steps in) and `pull.resumed` (it picked back up); each
109+/// uses the fields that apply to it.
103110 #[derive(Debug, Default, Serialize)]
104111 #[serde(rename_all = "camelCase")]
105112 pub struct PullEvent {
127134 /// How sure g1t is of a g1t agent's change, once it has worked that out.
128135 #[serde(skip_serializing_if = "Option::is_none")]
129136 pub confidence: Option<crate::work::Confidence>,
137+ /// On `pull.assigned`: the people it is now assigned to.
138+ #[serde(skip_serializing_if = "Option::is_none")]
139+ pub assignees: Option<Vec<String>>,
140+ /// On `pull.assigned`: those newly assigned.
141+ #[serde(skip_serializing_if = "Option::is_none")]
142+ pub added: Option<Vec<String>>,
143+ /// On `pull.review_requested`: the reviewers newly asked; on
144+ /// `pull.review_request_removed`, those no longer asked.
145+ #[serde(skip_serializing_if = "Option::is_none")]
146+ pub reviewers: Option<Vec<String>>,
147+ /// On `pull.stalled`: why g1t stopped, and what would start it again.
148+ #[serde(skip_serializing_if = "Option::is_none")]
149+ pub detail: Option<String>,
150+}
151+
152+/// `deployment.succeeded` and `deployment.failed`: a build of a project
153+/// finished, for production or for one pull request's preview.
154+#[derive(Debug, Serialize)]
155+#[serde(rename_all = "camelCase")]
156+pub struct DeploymentEvent {
157+ pub deployment_id: String,
158+ pub project_id: String,
159+ pub repo_id: String,
160+ pub workspace: String,
161+ /// The project's slug.
162+ pub project: String,
163+ /// `production` or `preview`.
164+ pub kind: String,
165+ pub branch: Option<String>,
166+ /// For a preview: its pull request.
167+ pub number: Option<u32>,
168+ pub commit: String,
169+ /// Where the deployment is on the site, such as
170+ /// `/acme/rocket/deployments/dpl_1`.
171+ pub path: String,
172+ /// For a failure: what went wrong.
173+ pub error: Option<String>,
174+ /// For a success: whether the deployment before it, of the same app, failed.
175+ pub recovered: bool,
176+ /// Who started it, by username, or `g1t`.
177+ pub triggered_by: String,
130178 }
131179
132180 /// `checks.completed`: a run of an issue's acceptance checks against a pull
+8−1
1515 use crate::{User, Viewer};
1616
1717 /// Every event a webhook can be sent, in the order people are shown them.
18−pub const EVENT_TYPES: [&str; 39] = [
18+pub const EVENT_TYPES: [&str; 46] = [
1919 "git.push",
2020 "branch.renamed",
2121 "repo.created",
4545 "pull.merge_requested",
4646 "pull.merged",
4747 "pull.closed",
48+ "pull.assigned",
49+ "pull.review_requested",
50+ "pull.review_request_removed",
51+ "pull.stalled",
52+ "pull.resumed",
4853 "agent.asked",
4954 "checks.completed",
5055 "review.completed",
5156 "workflow.completed",
57+ "deployment.succeeded",
58+ "deployment.failed",
5259 "queue.changed",
5360 "session.appended",
5461 "package.published",
+4−0
11361136 pub struct StallArgs {
11371137 pub pull_id: String,
11381138 pub reason: String,
1139+ /// The person who stopped it, by id, when someone did: they are not
1140+ /// told it needs them.
1141+ #[serde(default)]
1142+ pub by: Option<String>,
11391143 }
11401144
11411145 /// `managed_pulls`: ids of the open pull requests g1t is seeing through,
+36−2
1919 repoId: string | null;
2020 };
2121
22+/** What `deployment.succeeded` and `deployment.failed` carry. */
23+export type DeploymentEventData = {
24+ deploymentId: string;
25+ projectId: string;
26+ repoId: string;
27+ workspace: string;
28+ project: string;
29+ kind: "production" | "preview";
30+ branch: string | null;
31+ number: number | null;
32+ commit: string;
33+ path: string;
34+ error: string | null;
35+ recovered: boolean;
36+ /** A username, or `g1t`. */
37+ triggeredBy: string;
38+};
39+
2240 /** The payload of the `repo.collaborator_*` events. */
2341 export type RepoCollaboratorData = {
2442 repoId: string;
121139 requestedBy?: { id: string; username: string };
122140 };
123141 "issue.updated": { issueId: string; repoId: string; number: number };
124− /** The people an issue is assigned to changed; `assignees` is the new set. */
125− "issue.assigned": { issueId: string; repoId: string; number: number; assignees: string[] };
142+ /** The people an issue is assigned to changed; `assignees` is the new set, `added` those newly assigned. */
143+ "issue.assigned": { issueId: string; repoId: string; number: number; assignees: string[]; added?: string[] };
126144 /** `resolvedBy` is the number of the pull request whose merge closed it. */
127145 "issue.closed": {
128146 issueId: string;
153171 /** A merge was asked for while the pull request was behind; it has to catch up first. */
154172 "pull.merge_requested": { pullId: string; repoId: string; number: number; issue?: number; confidence?: Confidence };
155173 "pull.closed": { pullId: string; repoId: string; number: number; issue?: number; confidence?: Confidence };
174+ /** People were assigned to a pull request: `assignees` is the new set, `added` those newly assigned. */
175+ "pull.assigned": { pullId: string; repoId: string; number: number; issue?: number; assignees: string[]; added: string[] };
176+ /** Reviewers were asked for a pull request (`reviewers`), or no longer are. */
177+ "pull.review_requested": { pullId: string; repoId: string; number: number; issue?: number; reviewers: string[] };
178+ "pull.review_request_removed": { pullId: string; repoId: string; number: number; issue?: number; reviewers: string[] };
179+ /** g1t stopped seeing a pull request through until a person steps in; `detail` says why. */
180+ "pull.stalled": { pullId: string; repoId: string; number: number; issue?: number; detail: string };
181+ /** A pull request g1t had stopped on is going again. */
182+ "pull.resumed": { pullId: string; repoId: string; number: number; issue?: number };
156183 /**
157184 * The pull request's head or its target moved and the files both changed
158185 * overlap: a sandbox should find out whether it still merges cleanly.
197224 };
198225 "session.appended": { pullId: string; repoId: string; number: number; count: number };
199226 /**
227+ * A build of a project finished, for production or one pull request's
228+ * preview (`number`). `path` is the deployment's page on the site;
229+ * `recovered`, on a success, says the build before it failed.
230+ */
231+ "deployment.succeeded": DeploymentEventData;
232+ "deployment.failed": DeploymentEventData;
233+ /**
200234 * A package version was published, such as an image pushed by
201235 * `docker push`. `tags` are the tags that now point to it; `repoId` (and
202236 * the event's) is the repository the package is linked to, if any.
+7−0
3838 "pull.merge_requested",
3939 "pull.merged",
4040 "pull.closed",
41+ "pull.assigned",
42+ "pull.review_requested",
43+ "pull.review_request_removed",
44+ "pull.stalled",
45+ "pull.resumed",
4146 "agent.asked",
4247 "checks.completed",
4348 "review.completed",
4449 "workflow.completed",
50+ "deployment.succeeded",
51+ "deployment.failed",
4552 "queue.changed",
4653 "session.appended",
4754 "package.published",
+49−0
4040 sandboxEstimateMicros,
4141 currentMovedPath,
4242 currentWorkspaceSlug,
43+ eventsClient,
4344 fail,
4445 identityClient,
4546 isProtectedWorkspace,
9293 PROJECTS: ServiceBinding;
9394 /** Secrets and variables: the actions service holds the one store. */
9495 ACTIONS: ServiceBinding;
96+ /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
97+ EVENTS?: ServiceBinding;
9598 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
9699 CLOUDFLARE_API_TOKEN?: string;
97100 CLOUDFLARE_ACCOUNT_ID: string;
10571060 await this.chargeBuild(row, seconds);
10581061 await this.notePeak(row.workspace);
10591062 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
1063+ await this.announce(row, null);
10601064 // A screenshot of production as it now is, for the project's overview.
10611065 if (row.kind === "production") {
10621066 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
11371141 // A failed build still used its sandbox.
11381142 if (seconds) await this.chargeBuild(row, seconds);
11391143 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
1144+ await this.announce(row, message.slice(0, 300));
1145+ }
1146+
1147+ /**
1148+ * Publishes a finished build: `deployment.failed` with what went wrong,
1149+ * or `deployment.succeeded`, saying whether the build of the same app
1150+ * before it failed. Whoever started it is the actor when it was a
1151+ * person known by id; otherwise `triggeredBy` names them, or g1t.
1152+ */
1153+ private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1154+ if (!this.env.EVENTS) return;
1155+ const previous = error
1156+ ? null
1157+ : await this.db
1158+ .prepare(
1159+ `SELECT status FROM deployments
1160+ WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1161+ ORDER BY created_at DESC LIMIT 1`,
1162+ )
1163+ .bind(row.script, row.id, row.created_at)
1164+ .first<{ status: string }>();
1165+ const byId = row.created_by.startsWith("usr_");
1166+ const event = {
1167+ source: "deployments",
1168+ repoId: row.repo_id,
1169+ actor: byId ? row.created_by : null,
1170+ data: {
1171+ deploymentId: row.id,
1172+ projectId: row.project_id,
1173+ repoId: row.repo_id,
1174+ workspace: row.workspace,
1175+ project: row.slug,
1176+ kind: row.kind,
1177+ branch: row.branch,
1178+ number: row.kind === "preview" ? row.number : null,
1179+ commit: row.commit_sha,
1180+ path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1181+ error,
1182+ recovered: previous?.status === "failed",
1183+ triggeredBy: byId ? "" : row.created_by,
1184+ },
1185+ };
1186+ await eventsClient(this.env.EVENTS)
1187+ .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1188+ .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
11401189 }
11411190
11421191 /** Each build is charged by the second, from the first, at the container price plus the margin. */
+4−1
2626 // Secrets and variables, with who may read each.
2727 { "binding": "ACTIONS", "service": "g1t-actions" },
2828 // Production's screenshot, asked for once per deploy (services/og).
29− { "binding": "SCREENSHOTS", "service": "g1t-og", "entrypoint": "Screenshots" }
29+ { "binding": "SCREENSHOTS", "service": "g1t-og", "entrypoint": "Screenshots" },
30+ // Each finished build is published: deployment.succeeded or
31+ // deployment.failed, for the inbox, webhooks and workflows.
32+ { "binding": "EVENTS", "service": "g1t-events" }
3033 ],
3134 // Pull requests opened, pushed to, closed and merged; pushes to the
3235 // default branch.
+1−0
313313 self.stall(StallArgs {
314314 pull_id: pull_id.to_owned(),
315315 reason,
316+ by: None,
316317 })
317318 .await?;
318319 self.note(repo_id, number, (AGENT_ID, AGENT_NAME), &noted).await?;
+42−0
648648 ("assigned", "unassigned"),
649649 )
650650 .await?;
651+ let added: Vec<String> = assignees.iter().filter(|name| !before.contains(name)).cloned().collect();
651652 self.publish(
652653 "issue.assigned",
653654 &issue.repo_id,
654655 &a.actor,
655656 IssueEvent {
656657 assignees: Some(assignees),
658+ added: Some(added),
657659 ..Self::issue_event(&issue)
658660 },
659661 )
14721474 )
14731475 .await?;
14741476 }
1477+ // Who was newly assigned or asked to review, and whose request was
1478+ // withdrawn: the inbox tells them, and webhooks say so.
1479+ let newly = |after: &[String], before: &[String]| -> Vec<String> {
1480+ after.iter().filter(|name| !before.contains(name)).cloned().collect()
1481+ };
1482+ if let Some(assignees) = &assignees {
1483+ let added = newly(assignees, &pull.assignees);
1484+ if !added.is_empty() {
1485+ self.publish(
1486+ "pull.assigned",
1487+ &pull.repo_id,
1488+ &a.actor,
1489+ PullEvent {
1490+ assignees: Some(assignees.clone()),
1491+ added: Some(added),
1492+ ..Self::pull_event(&pull)
1493+ },
1494+ )
1495+ .await?;
1496+ }
1497+ }
1498+ if let Some(reviewers) = &reviewers {
1499+ for (kind, names) in [
1500+ ("pull.review_requested", newly(reviewers, &pull.reviewers)),
1501+ ("pull.review_request_removed", newly(&pull.reviewers, reviewers)),
1502+ ] {
1503+ if !names.is_empty() {
1504+ self.publish(
1505+ kind,
1506+ &pull.repo_id,
1507+ &a.actor,
1508+ PullEvent {
1509+ reviewers: Some(names),
1510+ ..Self::pull_event(&pull)
1511+ },
1512+ )
1513+ .await?;
1514+ }
1515+ }
1516+ }
14751517 Ok(match self.pull(&pull.repo_id, pull.number).await? {
14761518 Some(pull) => Outcome::Ok(pull),
14771519 None => no_pull(),
+40−1
11401140 self.stall(StallArgs {
11411141 pull_id: pull.id.clone(),
11421142 reason: format!("g1t could not merge this: {}", failure.message),
1143+ by: None,
11431144 })
11441145 .await?;
11451146 Ok(())
12751276 "It was brought up to date but could not be merged: {}",
12761277 failure.message
12771278 ),
1279+ by: None,
12781280 })
12791281 .await?;
12801282 }
13411343 })
13421344 }
13431345
1346+ /// Stops seeing a pull request through until a person steps in. The
1347+ /// first stop is published (`pull.stalled`), which tells its people
1348+ /// that it needs them; a stop on one already stopped only says why.
13441349 pub(crate) async fn stall(&self, a: StallArgs) -> Result<bool> {
1350+ let before = self.pull_by_id(&a.pull_id).await?;
1351+ let was_stalled = self.is_stalled(&a.pull_id).await?;
13451352 self.db
13461353 .prepare(
13471354 "UPDATE pulls
13541361 .await?;
13551362 self.db
13561363 .prepare("UPDATE pulls SET stage = 'needs_you', stage_detail = ? WHERE id = ?")
1357− .bind(&[a.reason.trim().into(), a.pull_id.into()])?
1364+ .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
13581365 .run()
13591366 .await?;
1367+ if let Some(pull) = before.filter(|pull| !was_stalled && pull.status == PullStatus::Open) {
1368+ self.publish_as(
1369+ "pull.stalled",
1370+ &pull.repo_id,
1371+ a.by.clone(),
1372+ g1t_contracts::events::PullEvent {
1373+ detail: Some(a.reason.trim().to_owned()),
1374+ ..Self::pull_event(&pull)
1375+ },
1376+ )
1377+ .await?;
1378+ }
13601379 Ok(true)
13611380 }
13621381
1382+ /// Whether g1t has stopped seeing the pull request through.
1383+ pub(crate) async fn is_stalled(&self, pull_id: &str) -> Result<bool> {
1384+ Ok(self
1385+ .db
1386+ .prepare("SELECT 1 AS value FROM pulls WHERE id = ? AND stalled IS NOT NULL")
1387+ .bind(&[pull_id.into()])?
1388+ .first::<u32>(Some("value"))
1389+ .await?
1390+ .is_some())
1391+ }
1392+
1393+ /// Says that a pull request g1t had stopped on is going again, which
1394+ /// closes what it was waiting on a person for.
1395+ pub(crate) async fn announce_resumed(&self, pull_id: &str, actor: Option<String>) -> Result<()> {
1396+ if let Some(pull) = self.pull_by_id(pull_id).await? {
1397+ self.publish_as("pull.resumed", &pull.repo_id, actor, Self::pull_event(&pull)).await?;
1398+ }
1399+ Ok(())
1400+ }
1401+
13631402 pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> {
13641403 let rows = self
13651404 .db
+4−0
567567 return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found."));
568568 };
569569 // A person asking outranks a stop and the limit on revisions.
570+ let was_stalled = self.is_stalled(&pull.id).await?;
570571 self.db
571572 .prepare("UPDATE pulls SET stalled = NULL WHERE id = ?")
572573 .bind(&[pull.id.as_str().into()])?
573574 .run()
574575 .await?;
576+ if was_stalled {
577+ self.announce_resumed(&pull.id, Some(actor.id.clone())).await?;
578+ }
575579 if !self.claim(&pull.id, "revision", REVISION_MINUTES, true).await? {
576580 return Ok(Outcome::fail(
577581 FailureCode::Conflict,
+1−0
425425 "{} stopped the agent's {} run. Ask for a review, a revision or a catch-up to start again.",
426426 a.actor.username, run.kind
427427 ),
428+ by: Some(a.actor.id.clone()),
428429 })
429430 .await?;
430431 self.note(
+1−0
261261 .first::<crate::rows::ValueRow>(None)
262262 .await?;
263263 if resumed.is_some() {
264+ self.announce_resumed(&head.id, None).await?;
264265 self.note(
265266 &a.repo_id,
266267 head.number,