Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge branch 'worktree-agent-aa8485640dce3cc93' into ship-batch

syntaqxcommitted Parents48e814c10396abBrowse files
21 files+649−1080/21 viewed
+7−6
2626
2727 | Source | Rows |
2828 | --- | --- |
29−| Code | A failed production build; a pull request ready for you to merge; a review asked of you by name; an invitation to a repository; a job waiting for a self-hosted runner |
30−| Notifications | An unread notification in this workspace that is a warning (someone or something is waiting) or a failure |
29+| Code | A failed production build; your open pull request that can't land until you act (it conflicts, a required check fails, someone asked for changes, approvals are missing); your pull request that meets everything its branch asks for, ready for you to merge; a review asked of you by name; an invitation to a repository; a job waiting for a self-hosted runner |
30+| Notifications | An unread notification in this workspace that is a warning (someone or something is waiting) or a failure. One whose cause is over, such as a workflow that has passed since or a pull request that merged, has [settled](/guides/notifications/#threads-that-settle-themselves) and is not here |
3131 | Chat | A conversation that mentions you, or a direct message you haven't read. Muted conversations are left out |
3232
3333 A notification about something already on the list, such as a pull
3737 waited, longest first:
3838
3939 1. A failed production build.
40−2. Finished work that only waits for you: a pull request to merge, a
40+2. Your pull request that can't land until you act on it.
41+3. Finished work that only waits for you: your pull request to merge, a
4142 review, an invitation.
42−3. Something that may be stuck: a job without a runner.
43−4. Unread warnings and failures in your notifications.
44−5. Mentions and direct messages.
43+4. Something that may be stuck: a job without a runner.
44+5. Unread warnings and failures in your notifications.
45+6. Mentions and direct messages.
4546
4647 The list shows the first eight rows; **Show more** opens the rest. The
4748 sentence under the page's heading starts with how many things need you.
+33−8
5252 | `assign` | assigned | You were assigned, or you are an assignee. |
5353 | `mention` | mentioned | Someone mentioned you with `@username`, or you were mentioned on it before. |
5454 | `team_mention` | team mentioned | Someone mentioned a [team](/guides/teams/#mentions) you are in with `@workspace/team`, or a team you are in was mentioned on it before. |
55−| `ci_activity` | CI activity | A check, workflow or deployment on your work finished badly, or recovered. |
55+| `ci_activity` | CI activity | A check, workflow or deployment on your work finished badly. |
5656 | `security_alert` | security alert | A scan found new secrets, code scanning alerts or vulnerable dependencies on a repository you look after (one thread per repository and kind; see [Security](#security)), a push of yours that push protection refused, a [vulnerability report](/guides/security/advisories/#private-vulnerability-reporting) sent to a repository you look after, a [bypass request](/guides/security/secret-protection/#delegated-bypass) to review or its answer, or your security digest. The workspace's owners and security managers hear of these; watchers who chose **Security alerts** do too, if they can see findings. |
57−| `state_change` | state changed | It was closed, reopened or merged. |
57+| `state_change` | state changed | It was closed, reopened or merged, or auto-merge you turned on was turned off. |
5858 | `author` | your work | You opened it. Also an owner's answer to your [token made for a workspace](/guides/authentication/#create-a-token) waiting for approval, or its revocation. |
5959 | `comment` | commented | You commented on it. |
6060 | `manual` | subscribed | You subscribed to it yourself. |
7777 | A workflow failed on a branch | Whoever pushed the commit it ran on | `ci_activity` | Error |
7878 | A preview of a pull request failed to deploy | The person the pull request belongs to, and people watching deployments | `ci_activity`, or `subscribed` for watchers | Error |
7979 | Production failed to deploy | Whoever pushed or started it, and people watching deployments | `ci_activity`, or `subscribed` for watchers | Error |
80−| A deployment went live | People watching deployments; after a failure, also whoever was told of the failure | `ci_activity`, or `subscribed` for watchers | Success |
81−| A pull request was merged | Everyone subscribed to it, and people watching pull requests | `state_change`, or `subscribed` for watchers | Success |
82−| An issue or pull request was closed, or an issue was reopened | Everyone subscribed to it, and people watching its kind | `state_change`, or `subscribed` for watchers | Info |
80+| A deployment went live | People watching deployments. It also [settles](#threads-that-settle-themselves) the failure before it | `subscribed` | Success |
81+| [Auto-merge](/guides/pull-requests/#auto-merge) was turned off by someone else, or by g1t because the pull request can no longer merge on its own | Whoever turned it on. Not when g1t handed the pull request to the merge queue, which lands it | `state_change` | Needs you |
82+| A pull request was merged, or an issue or pull request was closed | Its thread's history, for everyone subscribed to it and people watching its kind; the thread [settles](#threads-that-settle-themselves) at once, so it is neither unread nor pushed to you | `state_change`, or `subscribed` for watchers | Success for a merge, else Info |
83+| An issue was reopened | Everyone subscribed to it, and people watching issues | `state_change`, or `subscribed` for watchers | Info |
8384 | Someone mentioned you with `@username` in a comment | You | `mention` | Info |
8485 | Someone mentioned a team with `@workspace/team` in a comment, or in an issue or pull request they opened | Everyone in the team and its child teams, when the team's notifications are on and the writer can see the team | `team_mention` | Info |
8586 | Someone commented on an issue or pull request | Everyone subscribed to it, and people watching its kind | Why each is subscribed, or `subscribed` for watchers | Info, or Success for an approval |
8990 request for changes always reaches that person. A mention in code or in a
9091 quoted line does not count.
9192
92−When a review request to you is removed, that thread moves to Done, since
93−it no longer needs you. New activity brings it back, as with any thread.
94−
9593 You only see items about repositories you can read. If you lose access to a
9694 repository, its items leave your notifications the next time you open them.
9795
96+## Threads that settle themselves
97+
98+A thread that leaves you nothing to do moves to Done by itself, read, the
99+moment what it told you of is over: a later run passes, the pull request
100+merges, the review is no longer asked of you. It leaves your unread count,
101+the bell, [Home](/guides/home/)'s **Needs you** and `g1t status`, and
102+stays under **Done** with its history. New activity brings it back, as with
103+any thread.
104+
105+| A thread about | Settles when |
106+| --- | --- |
107+| Anything on an issue or pull request | The pull request is merged or closed, or the issue is closed. A pull request's previews settle with it |
108+| Your own thread on an issue or pull request | You comment on it or review it |
109+| A review asked of you | The request to you, or to your team, is removed |
110+| Checks that failed on your pull request | Its checks pass on its latest commit |
111+| A workflow that failed on a branch | That workflow passes on that branch |
112+| A deployment that failed | That project's production, or that preview, deploys |
113+| A deployment waiting for your review | Its workflow run ends, however it ended |
114+| A push protection bypass to review | Someone decides it |
115+| A member's token waiting for your approval | An owner approves, denies or revokes it |
116+| A workspace invitation | It is accepted, declined or revoked |
117+| A repository's security alerts | A scan finds none of that kind open |
118+| g1t stopping on a change it made for you | It picks back up, its head moves, a merge is asked for, or it is merged or closed |
119+
120+What g1t does itself and leaves nothing for you to do is never a
121+notification: what landed and what deployed is on [Home](/guides/home/).
122+
98123 ## Subscriptions
99124
100125 You are **subscribed** to an issue or pull request, and hear of what
+6−0
422422 so a push nobody reviewed never lands by itself. Turning it on and off
423423 publishes `pull.auto_merge_enabled` and `pull.auto_merge_disabled`.
424424
425+When auto-merge turns off without you, you get a notification saying
426+why, since it will not merge on its own now. Where the repository uses a
427+merge queue, a pull request auto-merge hands to the queue lands as the
428+queue decides; that turns auto-merge off too, but nothing is left for you
429+to do, so it tells no one.
430+
425431 ### Closing issues
426432
427433 When a pull request merges into the default branch, it closes the issue it
+1−1
9393 | `comment.deleted` | A comment was [deleted](/guides/pull-requests/#editing-and-deleting-comments). `data.comment_id`, `data.number`, `data.pull_id` on a pull request, and `data.comment` as it was: `id`, `body`, `author` (`id` and `username`), `created_at`, `path` and `line`. |
9494 | `commit_comment.created`, `commit_comment.edited`, `commit_comment.deleted` | A [comment on a commit](/guides/pull-requests/#commit-comments) was made, edited or deleted. `data.comment_id`, `data.commit` and `data.comment` (`id`, `commit`, `path`, `line`, `author`, `body`, `created_at`, `updated_at`); on edit, `data.changes.body.from`, what it said before. |
9595 | `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`. `actor` is whoever caused the event. |
96−| `pull.auto_merge_enabled`, `pull.auto_merge_disabled` | [Auto-merge](/guides/pull-requests/#auto-merge) was turned on for a pull request, or off: by a person, or by g1t when it merged, closed or someone else pushed. `data.number`; on enable, `data.detail` is the merge method; on disable, `data.detail` says why g1t turned it off, and `data.auto_merge_by` names who had turned it on. |
96+| `pull.auto_merge_enabled`, `pull.auto_merge_disabled` | [Auto-merge](/guides/pull-requests/#auto-merge) was turned on for a pull request, or off: by a person, or by g1t when it merged, closed or someone else pushed. `data.number`; on enable, `data.detail` is the merge method; on disable, `data.detail` says why g1t turned it off, `data.auto_merge_by` names who had turned it on, and `data.queued` is `true` when g1t turned it off because the pull request joined the merge queue, which lands it. |
9797 | `pull.head_branch_deleted`, `pull.head_branch_restored` | A merged or closed pull request's [branch](/guides/pull-requests/#deleting-the-branch) was deleted, on merge or by hand, or made again. `data.number` and `data.detail`, the branch. |
9898 | `pull.reopened`, `pull.converted_to_draft` | A closed pull request was [reopened](/guides/pull-requests/#reopen-a-pull-request), or one ready for review was [turned back into a draft](/guides/pull-requests/#convert-to-a-draft). The same data as `pull.closed`; on reopen, `data.commit` is its head. |
9999 | `pull.assigned` | People were assigned to a pull request. `data.assignees` is everyone assigned now, `data.added` those newly assigned. |
+1−0
132132 pull_blocked: <GitPullRequest size={16} className="text-warn" />,
133133 review: <GitPullRequest size={16} className="text-info" />,
134134 invitation: <Mail size={16} className="text-info" />,
135+ ready: <GitMerge size={16} className="text-success" />,
135136 runner: <Server size={16} className="text-warn" />,
136137 notification: <Bell size={16} className="text-warn" />,
137138 chat: <AtSign size={16} className="text-accent" />,
+1−0
5959 stalled: "border-warn/35 bg-warn/10 text-warn",
6060 invited: "border-accent/35 bg-accent/10 text-accent",
6161 needs_review: "border-info/35 bg-info/10 text-info",
62+ ready: "border-success/35 bg-success/10 text-success",
6263 };
6364
6465 function Chip({ children, tone }: { children: ReactNode; tone: string }) {
+9−0
8080 assert.equal(deploy.stake?.text, "Production on an older build");
8181 assert.equal(codeRow(need({ key: "review:p2", reason: "needs_review", facts: [{ label: "Lines", value: "+10 −2", tone: null }] })).stake?.text, "+10 −2");
8282 assert.equal(codeRow(need({ key: "runner:j1", reason: "stalled", facts: [{ label: "Waiting", value: "2 h", tone: "warn" }] })).stake?.text, "Waiting 2 h");
83+ // Your pull request that meets everything: merge it, beside reviews asked of you.
84+ const ready = codeRow(need({ key: "ready:p3", reason: "ready", ask: "Ready to land when you merge it." }));
85+ assert.equal(ready.kind, "ready");
86+ assert.equal(ready.stake?.text, "Ready to merge");
87+ assert.equal(ready.action.label, "Merge");
88+ // One that needs you (a conflict, a request for changes) blocks it.
89+ const conflict = codeRow(need({ key: "pull:p4", reason: "blocking", ask: "It conflicts with the branch it will land on." }));
90+ assert.equal(conflict.kind, "pull_blocked");
91+ assert.equal(conflict.stake?.text, "Blocking");
8392 });
8493
8594 const row = (over: Partial<AttentionRow>): AttentionRow => ({
+8−2
730730 * What a row is. Tiers, most pressing first:
731731 * 0. production is down to an older build (`deploy`);
732732 * 1. a change that can't land until someone acts on it (`pull_blocked`);
733− * 2. something waiting only for you (a review, an invitation);
733+ * 2. something waiting only for you (a review, an invitation, your pull
734+ * request that is ready to merge);
734735 * 3. a job waiting for a runner that is not there;
735736 * 4. an unread notification that is a warning or a failure;
736737 * 5. a mention or a direct message.
737738 */
738−export type AttentionKind = "deploy" | "pull_blocked" | "review" | "invitation" | "runner" | "notification" | "chat";
739+export type AttentionKind = "deploy" | "pull_blocked" | "review" | "invitation" | "ready" | "runner" | "notification" | "chat";
739740
740741 export const TIER: Record<AttentionKind, number> = {
741742 deploy: 0,
742743 pull_blocked: 1,
743744 review: 2,
744745 invitation: 2,
746+ ready: 2,
745747 runner: 3,
746748 notification: 4,
747749 chat: 5,
801803 pull_blocked: "This change can't land until someone fixes it.",
802804 review: "You were asked by name, so it waits for your verdict.",
803805 invitation: "It is addressed to you; no one else can answer it.",
806+ ready: "Everything it needs is met; it lands when you merge it.",
804807 runner: "A job is waiting for a runner that is not there.",
805808 notification: "It is the most pressing unread notification.",
806809 chat: "Nothing else is waiting, and this is the oldest message for you.",
854857 }
855858 case "runner":
856859 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
860+ // Your pull request, which meets everything its branch asks for.
861+ case "ready":
862+ return { ...base, kind: "ready", stake: { text: "Ready to merge", tone: null }, action: { label: "Merge", to: need.to } };
857863 default:
858864 return {
859865 ...base,
+26−3
197197
198198 const activeList = (active ?? []).flatMap((item) => {
199199 const repo = known.get(item.pull.repoId);
200− return repo ? [{ pull: item.pull, repo }] : [];
200+ return repo ? [{ pull: item.pull, lifecycle: item.lifecycle, repo }] : [];
201201 });
202202 const overviewList = okOr(overview ?? null);
203203 const projectsOk = okOr(projectList ?? null);
285285 });
286286 }
287287 }
288− for (const { pull, repo } of activeList) {
288+ for (const { pull, lifecycle, repo } of activeList) {
289+ if (pull.status !== "open") continue;
290+ // In this workspace, when the page is for one.
291+ if (slug && !inWorkspace(repo)) continue;
292+ const where = `${repo.namespace}/${repo.name}#${pull.number}`;
293+ const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
294+ // Your pull request, where its lifecycle says it stands (work keeps it
295+ // fresh as checks, reviews and pushes arrive): someone has to step in,
296+ // or everything is met and it lands when you merge it.
297+ if (lifecycle?.stage === "needs_you") {
298+ const conflict = /conflict/i.test(lifecycle.detail);
299+ const key = `pull:${pull.id}`;
300+ needs.push({ key, kind: conflict ? "conflict" : "stalled", title: pull.title, detail: lifecycle.detail, to, action: conflict ? "Resolve" : "Decide", at: Date.parse(pull.updatedAt), where });
301+ extras.set(key, { ...pullExtra(pull, repo), quick: null });
302+ continue;
303+ }
304+ if (lifecycle?.stage === "ready") {
305+ const key = `ready:${pull.id}`;
306+ needs.push({ key, kind: "ready", title: pull.title, detail: "Ready to land when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where });
307+ // Its lifecycle says the required checks passed, which a list's merge-queue status does not.
308+ const [, ...rest] = pullExtra(pull, repo).facts ?? [];
309+ extras.set(key, { ...pullExtra(pull, repo), facts: [{ label: "Required checks", value: "Passing", tone: "good" }, ...rest], quick: null });
310+ continue;
311+ }
289312 // Taken out of the merge queue: its change failed combined with what was ahead.
290− if (pull.status !== "open" || pull.checkStatus !== "failed") continue;
313+ if (pull.checkStatus !== "failed") continue;
291314 const key = `pull:${pull.id}`;
292315 needs.push({
293316 key,
+2−0
3737 assert.equal(reasonFor({ kind: "invitation" }), "invited");
3838 assert.equal(reasonFor({ kind: "checks" }), "checks_failing");
3939 assert.equal(reasonFor({ kind: "runner" }), "stalled");
40+ // Ready to merge is not a review asked of anyone.
41+ assert.equal(reasonFor({ kind: "ready" }), "ready");
4042 });
4143
4244 test("why a need needs a person is said for every reason", () => {
+6−2
1818 // --- Reasons ----------------------------------------------------------------
1919
2020 /** Why something is waiting on a person, as the chip beside it says. */
21−export type Reason = "blocking" | "invited" | "needs_review" | "checks_failing" | "stalled";
21+export type Reason = "blocking" | "invited" | "needs_review" | "checks_failing" | "stalled" | "ready";
2222
2323 export const REASON_LABEL: Record<Reason, string> = {
2424 blocking: "Blocking",
2626 needs_review: "Review requested",
2727 checks_failing: "Checks failing",
2828 stalled: "Stalled",
29+ ready: "Ready to merge",
2930 };
3031
3132 /** The reason a need is shown, from its kind. */
3738 case "invitation":
3839 return "invited";
3940 case "review":
41+ return "needs_review";
4042 case "ready":
41− return "needs_review";
43+ return "ready";
4244 case "checks":
4345 return "checks_failing";
4446 case "stalled":
6769 return "It is addressed to you.";
6870 case "stalled":
6971 return "It has stopped moving and needs a look.";
72+ case "ready":
73+ return "Everything its branch asks for is met: its checks pass and it has the approvals it needs. It lands when you merge it.";
7074 }
7175 }
7276
+5−0
256256 /// auto-merge on, who is told it is off.
257257 #[serde(skip_serializing_if = "Option::is_none")]
258258 pub auto_merge_by: Option<String>,
259+ /// On `pull.auto_merge_disabled`: g1t turned it off because the pull
260+ /// request joined the merge queue, which lands it. Nothing is left for
261+ /// anyone to do, so nobody's inbox is told.
262+ #[serde(skip_serializing_if = "std::ops::Not::not")]
263+ pub queued: bool,
259264 /// On `pull.labeled` and `pull.unlabeled`: the label put on or taken off.
260265 #[serde(skip_serializing_if = "Option::is_none")]
261266 pub label: Option<EventLabel>,
+5−2
15651565 pub by: Option<String>,
15661566 }
15671567
1568−/// `managed_pulls`: ids of the open pull requests g1t is seeing through,
1569−/// in one repository or in all of them. Returns `Vec<String>`.
1568+/// `managed_pulls`: ids of the open pull requests to move along (`advance`):
1569+/// those g1t merges by itself or someone set to auto-merge, in one
1570+/// repository or in all of them; in one repository, also every open one
1571+/// whose lifecycle is remembered, to bring it up to date after a merge
1572+/// there. Returns `Vec<String>`.
15701573 #[derive(Debug, Default, Serialize, Deserialize)]
15711574 #[serde(rename_all = "camelCase")]
15721575 pub struct ManagedPullsArgs {
+5−1
888888 * however often this is called. For the runner service.
889889 */
890890 advance(pullId: string): Promise<Advance>;
891− /** Ids of the open pull requests g1t is seeing through. */
891+ /**
892+ * Ids of the open pull requests to move along: those g1t merges by itself
893+ * or someone set to auto-merge; with `repoId`, also every open one whose
894+ * lifecycle is remembered, to bring it up to date after a merge there.
895+ */
892896 managedPulls(repoId?: string): Promise<string[]>;
893897
894898 /** A repository's merge queue: what is in it, in order, and what recently left. */
+62−0
1+-- Settles inbox items that later events left with nothing to do, once, for
2+-- the items written before the inbox settled them as events arrive
3+-- (src/inbox.rs, "Settling"). Each is marked done, and read, as the event
4+-- would have marked it; it stays in Done with its history.
5+
6+-- A workflow that failed on a branch and passed there since.
7+UPDATE inbox_items
8+SET done_at = COALESCE(done_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
9+ read_at = COALESCE(read_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
10+WHERE done_at IS NULL
11+ AND thread LIKE '%/run/%'
12+ AND EXISTS (
13+ SELECT 1 FROM events e
14+ WHERE e.repo_id = inbox_items.repo_id
15+ AND e.type = 'workflow.completed'
16+ AND e.id > inbox_items.event_id
17+ AND json_extract(e.data, '$.conclusion') = 'success'
18+ AND json_extract(e.data, '$.pull') IS NULL
19+ AND inbox_items.thread = e.repo_id || '/run/'
20+ || COALESCE(NULLIF(json_extract(e.data, '$.path'), ''), json_extract(e.data, '$.workflow'))
21+ || '@' || replace(json_extract(e.data, '$.ref'), 'refs/heads/', '')
22+ );
23+
24+-- A deployment that failed and has succeeded since.
25+UPDATE inbox_items
26+SET done_at = COALESCE(done_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
27+ read_at = COALESCE(read_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
28+WHERE done_at IS NULL
29+ AND event = 'deployment.failed'
30+ AND EXISTS (
31+ SELECT 1 FROM events e
32+ WHERE e.repo_id = inbox_items.repo_id
33+ AND e.type = 'deployment.succeeded'
34+ AND e.id > inbox_items.event_id
35+ AND inbox_items.thread = e.repo_id || '/deploy/' || json_extract(e.data, '$.projectId') || '/'
36+ || COALESCE(CAST(json_extract(e.data, '$.number') AS TEXT), 'production')
37+ );
38+
39+-- Anything about an issue or pull request that was merged or closed since
40+-- (or by the event the item last told of), and a pull request's previews.
41+UPDATE inbox_items
42+SET done_at = COALESCE(done_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
43+ read_at = COALESCE(read_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
44+WHERE done_at IS NULL
45+ AND number IS NOT NULL
46+ AND EXISTS (
47+ SELECT 1 FROM events e
48+ WHERE e.repo_id = inbox_items.repo_id
49+ AND e.type IN ('pull.merged', 'pull.closed', 'issue.closed')
50+ AND e.id >= inbox_items.event_id
51+ AND json_extract(e.data, '$.number') = inbox_items.number
52+ AND (inbox_items.thread = e.repo_id || '#' || inbox_items.number
53+ OR (e.type <> 'issue.closed' AND inbox_items.thread GLOB e.repo_id || '/deploy/*/' || inbox_items.number))
54+ );
55+
56+-- Auto-merge g1t turned off because the merge queue took the pull request.
57+UPDATE inbox_items
58+SET done_at = COALESCE(done_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
59+ read_at = COALESCE(read_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
60+WHERE done_at IS NULL
61+ AND event = 'pull.auto_merge_disabled'
62+ AND body = 'It joined the merge queue.';
+419−76
1515 //! | `checks.completed`, failed or errored | the pull request's owner | ci_activity | error |
1616 //! | `workflow.completed`, failed | the pull request's owner, or whoever pushed | ci_activity | error |
1717 //! | `deployment.failed` | the pull request's owner, or whoever pushed; watchers | ci_activity | error |
18−//! | `deployment.succeeded` after a failure | the same | ci_activity | success |
18+//! | `deployment.succeeded` | watchers; it settles the failure before it | subscribed | success |
19+//! | `pull.auto_merge_disabled` | whoever turned it on, unless g1t handed it to the merge queue | state_change | warning |
1920 //! | `pull.merged`, `pull.closed`, `issue.closed`, `issue.reopened` | everyone subscribed | state_change | success for a merge, else info |
2021 //! | `comment.created` | everyone mentioned, the people of teams mentioned, then everyone subscribed | mention, team_mention, or why they are subscribed | info (success for an approval) |
2122 //! | `comment.created` by a workspace's agent | the same, shown as "Margo (agent)"; a review also tells the owner, "(advisory)" | the same, or author | the same |
2526 //! | `security_advisory.reported` | the workspace's owners and security managers | security_alert | warning |
2627 //! | `secret_scanning.bypass_requested`, `.bypass_reviewed` | the reviewers named; the requester | security_alert | warning; info |
2728 //!
29+//! What leaves nothing for a person to do settles their item about it
30+//! (marks it done): see "Settling" below for which event settles what.
31+//! A merge or a close reaches each thread's history but settles it at
32+//! once, so it is neither left unread nor pushed.
33+//!
2834 //! Watchers of a repository at `all` (or `custom`, for the kinds they
2935 //! chose) hear of every issue and pull request opened, commented on,
3036 //! closed, reopened or merged, and of deployments. Anyone who ignores the
187193 format!("{repo_id}#{number}")
188194 }
189195
196+/// The thread of a workflow on a branch: `<repo_id>/run/<workflow>@<branch>`.
197+pub fn run_thread(repo_id: &str, data: &serde_json::Value) -> String {
198+ let text = |key: &str| data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
199+ let branch = text("ref").unwrap_or_default();
200+ let branch = branch.strip_prefix("refs/heads/").unwrap_or(&branch).to_owned();
201+ let workflow = text("path").or_else(|| text("workflow")).unwrap_or_default();
202+ format!("{repo_id}/run/{workflow}@{branch}")
203+}
204+
205+/// The thread of a project's production, or of one pull request's preview:
206+/// `<repo_id>/deploy/<project_id>/<production|number>`.
207+pub fn deploy_thread(repo_id: &str, data: &serde_json::Value, number: Option<u32>) -> String {
208+ let which = number.map_or_else(|| "production".to_owned(), |number| number.to_string());
209+ format!("{repo_id}/deploy/{}/{which}", data["projectId"].as_str().unwrap_or_default())
210+}
211+
190212 /// The thread an event's items go to.
191213 pub fn thread_of(event: &Event, wanted: &Wanted, subject: Option<&InboxSubject>) -> Thread {
192214 let data = &event.data;
194216 match event.kind.as_str() {
195217 // A project's production, or one pull request's preview.
196218 "deployment.failed" | "deployment.succeeded" => {
197− let which = wanted.number.map_or_else(|| "production".to_owned(), |number| number.to_string());
198219 Thread {
199− key: format!("{}/deploy/{}/{which}", wanted.repo_id, text("projectId").unwrap_or_default()),
220+ key: deploy_thread(&wanted.repo_id, data, wanted.number),
200221 kind: Some(SubjectKind::Deploy),
201222 number: wanted.number,
202223 run_id: text("deploymentId"),
218239 },
219240 // A workflow on a branch: its next failure bumps the same thread.
220241 "workflow.completed" if subject.is_none() => {
221− let branch = text("ref").unwrap_or_default();
222− let branch = branch.strip_prefix("refs/heads/").unwrap_or(&branch).to_owned();
223− let workflow = text("path").or_else(|| text("workflow")).unwrap_or_default();
224242 Thread {
225− key: format!("{}/run/{workflow}@{branch}", wanted.repo_id),
243+ key: run_thread(&wanted.repo_id, data),
226244 kind: Some(SubjectKind::Run),
227245 number: None,
228246 run_id: text("runId"),
273291 }
274292 }
275293
276−/// The issue or pull request whose agent threads an event closes: what an
277−/// agent was waiting on a person for is over.
278−pub fn resolves(event: &Event) -> Option<String> {
279− if !RESUMES.contains(&event.kind.as_str()) {
280− return None;
294+// --- Settling ----------------------------------------------------------------
295+//
296+// An item is settled (marked done, and read) by the event that leaves
297+// nothing for its person to do about it, so the inbox, its count, Home's
298+// Needs you and `g1t status` show only what still waits on someone. A
299+// settled item stays in Done, with its history, until it is purged; new
300+// activity on its thread brings it back.
301+//
302+// | Item | Settled by |
303+// | --- | --- |
304+// | g1t stopped and needs you (`agent`) | `pull.resumed`, `pull.updated`, `pull.merge_requested`, `pull.merged`, `pull.closed` |
305+// | Anything on an issue or pull request | `issue.closed`, `pull.merged`, `pull.closed`, after it is told (`closes`) |
306+// | Your own item on a thread | your comment or review on it (`handled_by`) |
307+// | A review asked of you | `pull.review_request_removed` naming you or your team |
308+// | Checks failed on a pull request (`ci_activity`) | `checks.completed` passed on its head |
309+// | A workflow failed on a branch | the next `workflow.completed` success of that workflow on that branch |
310+// | A deployment failed | `deployment.succeeded` of that project's production, or that preview |
311+// | A deployment waits for your review | `workflow.completed` of its run, however it ended |
312+// | A push protection bypass to review | `secret_scanning.bypass_reviewed` |
313+// | A token waits for your approval | `token.approval_reviewed` |
314+// | A workspace invitation | `workspace_invitation.accepted`, `.declined`, `.revoked` |
315+// | A scan's alerts | the next scan's summary with none open (`refresh_security_thread`) |
316+
317+/// Which threads a settle reaches.
318+#[derive(Clone, Debug, PartialEq, Eq)]
319+pub enum Scope {
320+ /// One thread.
321+ Thread(String),
322+ /// Every thread matching a GLOB pattern, such as one pull request's
323+ /// previews in every project: `rep_1/deploy/*/7`.
324+ Matching(String),
325+}
326+
327+/// Items an event settles.
328+#[derive(Clone, Debug, PartialEq, Eq)]
329+pub struct Settle {
330+ pub scope: Scope,
331+ /// One person's item, or everyone's.
332+ pub username: Option<String>,
333+ /// Only items whose latest activity was for one of these reasons; any when empty.
334+ pub reasons: Vec<Reason>,
335+ /// Only items whose latest activity was one of these events; any when empty.
336+ pub events: Vec<&'static str>,
337+}
338+
339+impl Settle {
340+ fn thread(thread: impl Into<String>) -> Self {
341+ Settle {
342+ scope: Scope::Thread(thread.into()),
343+ username: None,
344+ reasons: Vec::new(),
345+ events: Vec::new(),
346+ }
347+ }
348+
349+ fn matching(pattern: impl Into<String>) -> Self {
350+ Settle {
351+ scope: Scope::Matching(pattern.into()),
352+ ..Settle::thread("")
353+ }
354+ }
355+
356+ fn because(mut self, reasons: &[Reason]) -> Self {
357+ self.reasons = reasons.to_vec();
358+ self
359+ }
360+
361+ fn after(mut self, events: &[&'static str]) -> Self {
362+ self.events = events.to_vec();
363+ self
364+ }
365+
366+ fn of(mut self, username: &str) -> Self {
367+ self.username = Some(username.trim().trim_start_matches('@').to_lowercase());
368+ self
369+ }
370+
371+ /// The statement that settles the items, as of `now`, naming whose it settled.
372+ fn statement(&self, db: &D1Database, now: &str) -> Result<D1PreparedStatement> {
373+ let mut values: Vec<JsValue> = vec![now.into()];
374+ let mut conditions = vec!["done_at IS NULL".to_owned()];
375+ let bind = |values: &mut Vec<JsValue>, value: &str| {
376+ values.push(value.into());
377+ format!("?{}", values.len())
378+ };
379+ match &self.scope {
380+ Scope::Thread(thread) => conditions.push(format!("thread = {}", bind(&mut values, thread))),
381+ Scope::Matching(pattern) => conditions.push(format!("thread GLOB {}", bind(&mut values, pattern))),
382+ }
383+ if let Some(username) = &self.username {
384+ conditions.push(format!("username = {}", bind(&mut values, username)));
385+ }
386+ if !self.reasons.is_empty() {
387+ let marks: Vec<String> = self.reasons.iter().map(|reason| bind(&mut values, reason.as_str())).collect();
388+ conditions.push(format!("reason IN ({})", marks.join(", ")));
389+ }
390+ if !self.events.is_empty() {
391+ let marks: Vec<String> = self.events.iter().map(|kind| bind(&mut values, kind)).collect();
392+ conditions.push(format!("event IN ({})", marks.join(", ")));
393+ }
394+ db.prepare(format!(
395+ "UPDATE inbox_items SET done_at = ?1, read_at = COALESCE(read_at, ?1) WHERE {} RETURNING username",
396+ conditions.join(" AND ")
397+ ))
398+ .bind(&values)
281399 }
400+}
401+
402+/// The issue or pull request an event names, as its thread.
403+fn numbered(event: &Event) -> Option<String> {
282404 let repo_id = event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone())?;
283405 let number = event.data["number"].as_u64().and_then(|n| u32::try_from(n).ok())?;
284406 Some(numbered_thread(&repo_id, number))
285407 }
286408
409+/// What an event settles before its own notices are written: what it
410+/// supersedes. A success after a failure, a review given or no longer
411+/// asked for, a request answered.
412+pub fn settles(event: &Event) -> Vec<Settle> {
413+ let data = &event.data;
414+ let text = |key: &str| data[key].as_str().filter(|value| !value.is_empty()).map(str::to_owned);
415+ let repo_id = text("repoId").or_else(|| event.repo_id.clone());
416+ let mut settled = Vec::new();
417+ let kind = event.kind.as_str();
418+ // What an agent was waiting on a person for is over.
419+ if RESUMES.contains(&kind)
420+ && let Some(thread) = numbered(event)
421+ {
422+ settled.push(Settle::thread(thread).because(&[Reason::Agent]));
423+ }
424+ match kind {
425+ // Reviews no longer asked for, of the people or the teams named.
426+ "pull.review_request_removed" => {
427+ if let Some(thread) = numbered(event) {
428+ let people = names(data, "reviewers").into_iter().chain(teams_asked(data).into_iter().flat_map(|(_, people)| people));
429+ for name in people.collect::<HashSet<_>>() {
430+ settled.push(Settle::thread(thread.clone()).because(&[Reason::ReviewRequested]).of(&name));
431+ }
432+ }
433+ }
434+ // A workspace invitation answered or revoked no longer waits on its person.
435+ _ if INVITATION_ANSWERED.contains(&kind) => {
436+ if let Some(thread) = text("thread").filter(|thread| thread.starts_with("invitation:")) {
437+ settled.push(Settle::thread(thread).because(&[Reason::ReviewRequested]));
438+ }
439+ }
440+ // A token approved, denied or revoked no longer waits on the owners.
441+ "token.approval_reviewed" => {
442+ let (thread, _) = workspace_notices(event, None);
443+ settled.push(Settle::thread(thread).because(&[Reason::ReviewRequested]));
444+ }
445+ // Every check on its head passes now (or none fails): the failure
446+ // its owner was told of is behind them.
447+ "checks.completed" if data["status"].as_str() == Some("passed") => {
448+ if let Some(thread) = numbered(event) {
449+ settled.push(Settle::thread(thread).because(&[Reason::CiActivity]));
450+ }
451+ }
452+ "workflow.completed" => {
453+ if let Some(repo_id) = &repo_id {
454+ // The workflow passes on the branch again: its failures there are over.
455+ if data["conclusion"].as_str() == Some("success") && data["pull"].as_u64().is_none() {
456+ settled.push(Settle::thread(run_thread(repo_id, data)));
457+ }
458+ // Its run is over, however it ended: no deployment of it waits for review.
459+ if let Some(run) = text("runId") {
460+ settled.push(Settle::matching(format!("{repo_id}/review/{run}/*")).because(&[Reason::ReviewRequested]));
461+ }
462+ }
463+ }
464+ // It deployed: the failure before it is over.
465+ "deployment.succeeded" => {
466+ if let Some(repo_id) = &repo_id {
467+ let number = data["number"].as_u64().and_then(|n| u32::try_from(n).ok());
468+ settled.push(Settle::thread(deploy_thread(repo_id, data, number)).after(&["deployment.failed"]));
469+ }
470+ }
471+ // A bypass decided no longer waits on its reviewers.
472+ "secret_scanning.bypass_reviewed" => {
473+ if let (Some(repo_id), Some(which)) = (&repo_id, text("requestId").or_else(|| text("alertId"))) {
474+ settled.push(Settle::thread(format!("{repo_id}/security/{which}")).because(&[Reason::SecurityAlert]));
475+ }
476+ }
477+ _ => {}
478+ }
479+ settled
480+}
481+
482+/// The events after which nothing is left to do about an issue or pull
483+/// request: every item about it is settled once its own notices are
484+/// written, so they reach each thread's history but nobody's inbox, and
485+/// are not pushed. What landed shows on Home instead.
486+pub const CLOSES: [&str; 3] = ["pull.merged", "pull.closed", "issue.closed"];
487+
488+/// What an event settles once its own notices are written: everything
489+/// about an issue or pull request that is over, a pull request's previews
490+/// with it.
491+pub fn closes(event: &Event) -> Vec<Settle> {
492+ if !CLOSES.contains(&event.kind.as_str()) {
493+ return Vec::new();
494+ }
495+ let Some(thread) = numbered(event) else {
496+ return Vec::new();
497+ };
498+ let mut settled = vec![Settle::thread(thread)];
499+ if event.kind.starts_with("pull.")
500+ && let (Some(repo_id), Some(number)) = (
501+ event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone()),
502+ event.data["number"].as_u64(),
503+ )
504+ {
505+ settled.push(Settle::matching(format!("{repo_id}/deploy/*/{number}")));
506+ }
507+ settled
508+}
509+
510+/// The item a person's own comment or review settles: theirs on that
511+/// thread, which they have answered. Never an agent's, nor g1t's.
512+pub fn handled_by(event: &Event, subject: Option<&InboxSubject>) -> Option<Settle> {
513+ if event.kind != "comment.created" {
514+ return None;
515+ }
516+ let comment = subject?.comment.as_ref().filter(|comment| !comment.event && comment.agent.is_none())?;
517+ if is_g1t_id(&comment.author.id) || is_g1t(&comment.author.username) {
518+ return None;
519+ }
520+ Some(Settle::thread(numbered(event)?).of(&comment.author.username))
521+}
522+
287523 /// Collects who is told, each once with the most specific reason, never
288524 /// the actor, never g1t, and never anyone ignoring the thread.
289525 struct Told<'a> {
437673 }
438674 // Whoever turned auto-merge on hears that it is off, unless they
439675 // turned it off themselves: it will not merge without them now.
676+ // Not when g1t handed it to the merge queue, which lands it: there
677+ // is nothing for them to do.
440678 ("pull.auto_merge_disabled", Some(pull)) => {
679+ if data["queued"].as_bool() == Some(true) {
680+ return told.notices;
681+ }
441682 if let Some(by) = data["autoMergeBy"].as_str() {
442683 let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty());
443684 let title = format!("Auto-merge was turned off on {at}");
539780 None => format!("Commit {}", short(data["commit"].as_str().unwrap_or_default())),
540781 },
541782 };
542− // A success is news to whoever answers for it only after a failure.
543− if failed || data["recovered"].as_bool() == Some(true) {
544− let title = if failed { title.clone() } else { format!("{what} is live again") };
783+ // A failure is whoever answers for it's to fix. A success after
784+ // one tells them nothing: it settles the failure's item instead
785+ // (`settles`), so there is nothing left in their inbox about it.
786+ if failed {
545787 match subject {
546788 Some(pull) => told.tell_person(pull.owner(), Reason::CiActivity, severity, &title, &body, true),
547789 None => {
9611203 repo_id: String,
9621204 url: String,
9631205 event_id: String,
1206+ /// Its event leaves nothing to do (`CLOSES`): settled once written.
1207+ closing: bool,
9641208 }
9651209
9661210 /// Writes the items a batch from the bus calls for. Never fails the batch:
9671211 /// what cannot be worked out is logged and left.
9681212 pub async fn deliver(db: &D1Database, sources: &Sources<'_>, events: &[Event]) {
969− if let Err(error) = resolve(db, events).await {
970− worker::console_error!("inbox: agent threads not closed: {error}");
1213+ // Whose items were settled, to tell their counts once at the end.
1214+ let mut settled: HashSet<String> = HashSet::new();
1215+ // What the batch supersedes goes first, so its own notices start afresh.
1216+ match settle(db, &events.iter().flat_map(settles).collect::<Vec<_>>()).await {
1217+ Ok(people) => settled.extend(people),
1218+ Err(error) => worker::console_error!("inbox: items not settled: {error}"),
1219+ }
1220+ deliver_events(db, sources, events, &mut settled).await;
1221+ // What is over goes last, after its own notices reached each thread's history.
1222+ match settle(db, &events.iter().flat_map(closes).collect::<Vec<_>>()).await {
1223+ Ok(people) => settled.extend(people),
1224+ Err(error) => worker::console_error!("inbox: closed threads not settled: {error}"),
1225+ }
1226+ if let Some(notify) = sources.notify {
1227+ for username in &settled {
1228+ tell_inbox_count(db, notify, username).await;
1229+ }
9711230 }
1231+}
1232+
1233+async fn deliver_events(db: &D1Database, sources: &Sources<'_>, events: &[Event], settled: &mut HashSet<String>) {
9721234 // A workspace's own notices, about no repository (workspace_notices).
9731235 for event in events.iter().filter(|event| WORKSPACE_EVENTS.contains(&event.kind.as_str())) {
9741236 if let Err(error) = deliver_workspace(db, event).await {
10211283 let mut watchers: HashMap<String, Vec<subscriptions::Watcher>> = HashMap::new();
10221284 let mut written: Vec<Written> = Vec::new();
10231285 for (event, wanted) in wanted {
1024− match deliver_one(db, sources, &names, &mut paths, &mut watchers, event, wanted).await {
1286+ match deliver_one(db, sources, &names, &mut paths, &mut watchers, event, wanted, settled).await {
10251287 Ok(mut news) => written.append(&mut news),
10261288 Err(error) => worker::console_error!("inbox: {} {} not delivered: {error}", event.kind, event.id),
10271289 }
10281290 }
1029− // Notify: every item that was news, live in the person's tabs (services/notify).
1291+ // Notify: every item that was news, live in the person's tabs
1292+ // (services/notify), but for what is over, which is settled at once:
1293+ // nobody is pushed a notification that is already done.
10301294 if let Some(notify) = sources.notify {
10311295 let live = written
10321296 .iter()
1297+ .filter(|item| !item.closing)
10331298 .map(|item| (item.notice.username.clone(), live_notification(&item.event_id, &item.notice, &item.url, &rfc3339(now_ms()))))
10341299 .collect::<Vec<_>>();
10351300 tell_live(notify, live).await;
1036− let people: HashSet<&str> = written.iter().map(|item| item.notice.username.as_str()).collect();
1301+ let people: HashSet<&str> = written.iter().filter(|item| !item.closing).map(|item| item.notice.username.as_str()).collect();
10371302 for username in people {
1038− tell_inbox_count(db, notify, username).await;
1303+ if !settled.contains(username) {
1304+ tell_inbox_count(db, notify, username).await;
1305+ }
10391306 }
10401307 }
10411308 if let Err(error) = email(db, sources.identity, written).await {
11511418 Ok(())
11521419 }
11531420
1154−/// Closes what an agent was waiting on a person for once it is over.
1155−async fn resolve(db: &D1Database, events: &[Event]) -> Result<()> {
1421+/// Settles items (see [`settles`]), in one batch. Returns whose it
1422+/// settled, so their counts can be told again.
1423+async fn settle(db: &D1Database, settled: &[Settle]) -> Result<HashSet<String>> {
1424+ if settled.is_empty() {
1425+ return Ok(HashSet::new());
1426+ }
11561427 let now = rfc3339(now_ms());
1157− let mut statements = Vec::new();
1158− for thread in events.iter().filter_map(resolves) {
1159− statements.push(
1160− db.prepare(
1161− "UPDATE inbox_items SET done_at = ?1, read_at = COALESCE(read_at, ?1)
1162− WHERE thread = ?2 AND reason = 'agent' AND done_at IS NULL",
1163− )
1164− .bind(&[now.as_str().into(), thread.into()])?,
1165− );
1428+ let statements = settled.iter().map(|settle| settle.statement(db, &now)).collect::<Result<Vec<_>>>()?;
1429+ #[derive(Deserialize)]
1430+ struct Whose {
1431+ username: String,
11661432 }
1167− // A workspace invitation answered or revoked no longer waits on its person.
1168− for event in events.iter().filter(|event| INVITATION_ANSWERED.contains(&event.kind.as_str())) {
1169− let Some(thread) = event.data["thread"].as_str().filter(|thread| thread.starts_with("invitation:")) else {
1170− continue;
1171− };
1172− statements.push(
1173− db.prepare(
1174− "UPDATE inbox_items SET done_at = ?1, read_at = COALESCE(read_at, ?1)
1175− WHERE thread = ?2 AND reason = 'review_requested' AND done_at IS NULL",
1176− )
1177− .bind(&[now.as_str().into(), thread.into()])?,
1178− );
1433+ let mut people = HashSet::new();
1434+ for result in db.batch(statements).await? {
1435+ people.extend(result.results::<Whose>()?.into_iter().map(|row| row.username));
11791436 }
1180− // Reviews no longer asked for are no longer waiting.
1181− for event in events.iter().filter(|event| event.kind == "pull.review_request_removed") {
1182− let (Some(repo_id), Some(number)) = (
1183− event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone()),
1184− event.data["number"].as_u64(),
1185− ) else {
1186− continue;
1187− };
1188− for name in names(&event.data, "reviewers") {
1189− statements.push(
1190− db.prepare(
1191− "UPDATE inbox_items SET done_at = ?1, read_at = COALESCE(read_at, ?1)
1192− WHERE thread = ?2 AND username = ?3 AND reason = 'review_requested' AND done_at IS NULL",
1193− )
1194− .bind(&[
1195− now.as_str().into(),
1196− format!("{repo_id}#{number}").into(),
1197− name.to_lowercase().into(),
1198− ])?,
1199− );
1200− }
1201− }
1202− if !statements.is_empty() {
1203− db.batch(statements).await?;
1204− }
1205− Ok(())
1437+ Ok(people)
12061438 }
12071439
1440+#[allow(clippy::too_many_arguments)]
12081441 async fn deliver_one(
12091442 db: &D1Database,
12101443 sources: &Sources<'_>,
12131446 watchers: &mut HashMap<String, Vec<subscriptions::Watcher>>,
12141447 event: &Event,
12151448 wanted: Wanted,
1449+ settled: &mut HashSet<String>,
12161450 ) -> Result<Vec<Written>> {
12171451 if !paths.contains_key(&wanted.repo_id) {
12181452 let path: Option<RepoPath> = g1t_kit::call(
12571491 },
12581492 };
12591493 let thread = thread_of(event, &wanted, subject.as_ref());
1494+ // Whoever commented has answered what was on the thread for them.
1495+ if let Some(handled) = handled_by(event, subject.as_ref()) {
1496+ settled.extend(settle(db, &[handled]).await?);
1497+ }
12601498 if !watchers.contains_key(&wanted.repo_id) {
12611499 watchers.insert(wanted.repo_id.clone(), subscriptions::watchers(db, &wanted.repo_id).await?);
12621500 }
13831621 repo_id: wanted.repo_id.clone(),
13841622 url: item_url.clone(),
13851623 event_id: event.id.clone(),
1624+ closing: CLOSES.contains(&event.kind.as_str()),
13861625 })
13871626 .collect())
13881627 }
22092448 }
22102449
22112450 #[test]
2451+ fn g1t_handing_a_pull_request_to_the_merge_queue_tells_no_one() {
2452+ let off = event(
2453+ "pull.auto_merge_disabled",
2454+ None,
2455+ json!({ "number": 7, "autoMergeBy": "bo", "detail": "It joined the merge queue.", "queued": true }),
2456+ );
2457+ assert!(notices(&off, "acme/rocket", &Actor::default(), Some(&pull()), &nobody()).is_empty());
2458+ }
2459+
2460+ #[test]
22122461 fn whatever_an_agent_waits_on_closes_when_it_goes_on() {
2462+ let agent = Settle::thread("rep_1#7").because(&[Reason::Agent]);
22132463 for kind in RESUMES {
2214− assert_eq!(resolves(&event(kind, None, json!({ "repoId": "rep_1", "number": 7 }))), Some("rep_1#7".into()));
2464+ assert!(settles(&event(kind, None, json!({ "repoId": "rep_1", "number": 7 }))).contains(&agent), "{kind}");
22152465 }
2216− assert_eq!(resolves(&event("comment.created", None, json!({ "repoId": "rep_1", "number": 7 }))), None);
2466+ assert!(settles(&event("comment.created", None, json!({ "repoId": "rep_1", "number": 7 }))).is_empty());
2467+ }
2468+
2469+ #[test]
2470+ fn a_merge_or_a_close_settles_everything_about_it() {
2471+ let merged = event("pull.merged", Some("usr_ana"), json!({ "repoId": "rep_1", "number": 7 }));
2472+ assert_eq!(closes(&merged), vec![Settle::thread("rep_1#7"), Settle::matching("rep_1/deploy/*/7")]);
2473+ let closed = event("pull.closed", None, json!({ "repoId": "rep_1", "number": 7 }));
2474+ assert_eq!(closes(&closed).len(), 2);
2475+ // An issue has no previews.
2476+ let issue = event("issue.closed", None, json!({ "repoId": "rep_1", "number": 3 }));
2477+ assert_eq!(closes(&issue), vec![Settle::thread("rep_1#3")]);
2478+ // Reopening is something to hear of, and settles nothing.
2479+ assert!(closes(&event("issue.reopened", None, json!({ "repoId": "rep_1", "number": 3 }))).is_empty());
2480+ assert!(settles(&event("issue.reopened", None, json!({ "repoId": "rep_1", "number": 3 }))).is_empty());
22172481 }
22182482
22192483 #[test]
2484+ fn a_workflow_passing_on_a_branch_settles_its_failures_there() {
2485+ let data = |conclusion: &str| {
2486+ json!({ "repoId": "rep_1", "runId": "run_9", "workflow": "Deploy", "path": ".g1t/workflows/deploy.yml", "ref": "refs/heads/main", "conclusion": conclusion, "number": 328 })
2487+ };
2488+ let failed = event("workflow.completed", Some("usr_bo"), data("failure"));
2489+ let thread = thread_of(&failed, &wants(&failed).unwrap(), None).key;
2490+ assert_eq!(thread, "rep_1/run/.g1t/workflows/deploy.yml@main");
2491+ let passed = event("workflow.completed", Some("usr_bo"), data("success"));
2492+ assert!(wants(&passed).is_none(), "a success tells no one");
2493+ assert_eq!(
2494+ settles(&passed),
2495+ vec![
2496+ Settle::thread(thread.clone()),
2497+ Settle::matching("rep_1/review/run_9/*").because(&[Reason::ReviewRequested]),
2498+ ]
2499+ );
2500+ // A failure or a cancelled run settles no failure, but ends what its deployments waited for.
2501+ for conclusion in ["failure", "cancelled"] {
2502+ assert_eq!(
2503+ settles(&event("workflow.completed", None, data(conclusion))),
2504+ vec![Settle::matching("rep_1/review/run_9/*").because(&[Reason::ReviewRequested])]
2505+ );
2506+ }
2507+ // On a pull request, its checks passing settles the failure instead.
2508+ let on_pull = event("workflow.completed", None, json!({ "repoId": "rep_1", "runId": "run_9", "conclusion": "success", "pull": 7 }));
2509+ assert!(!settles(&on_pull).iter().any(|settle| settle.scope == Scope::Thread(thread.clone())));
2510+ }
2511+
2512+ #[test]
2513+ fn checks_passing_settle_the_failure_its_owner_was_told_of() {
2514+ let passed = event("checks.completed", None, json!({ "repoId": "rep_1", "number": 7, "status": "passed" }));
2515+ assert_eq!(settles(&passed), vec![Settle::thread("rep_1#7").because(&[Reason::CiActivity])]);
2516+ let failed = event("checks.completed", None, json!({ "repoId": "rep_1", "number": 7, "status": "failed" }));
2517+ assert!(settles(&failed).is_empty());
2518+ }
2519+
2520+ #[test]
2521+ fn a_deployment_settles_the_failure_before_it() {
2522+ let live = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1" }));
2523+ assert_eq!(settles(&live), vec![Settle::thread("rep_1/deploy/prj_1/production").after(&["deployment.failed"])]);
2524+ let preview = event("deployment.succeeded", None, json!({ "repoId": "rep_1", "projectId": "prj_1", "number": 7 }));
2525+ assert_eq!(settles(&preview), vec![Settle::thread("rep_1/deploy/prj_1/7").after(&["deployment.failed"])]);
2526+ }
2527+
2528+ #[test]
2529+ fn requests_answered_settle_what_they_asked() {
2530+ let removed = event(
2531+ "pull.review_request_removed",
2532+ None,
2533+ json!({ "repoId": "rep_1", "number": 7, "reviewers": ["Bo"], "teams": [{ "team": "acme/web", "notified": ["cy"] }] }),
2534+ );
2535+ let mut people: Vec<Option<String>> = settles(&removed).into_iter().map(|settle| settle.username).collect();
2536+ people.sort();
2537+ assert_eq!(people, vec![Some("bo".to_owned()), Some("cy".to_owned())]);
2538+ let token = event("token.approval_reviewed", None, json!({ "workspace": "acme", "tokenId": "tok_1" }));
2539+ assert_eq!(settles(&token), vec![Settle::thread("workspace:acme/token/tok_1").because(&[Reason::ReviewRequested])]);
2540+ let bypass = event("secret_scanning.bypass_reviewed", None, json!({ "repoId": "rep_1", "requestId": "byp_1" }));
2541+ assert_eq!(settles(&bypass), vec![Settle::thread("rep_1/security/byp_1").because(&[Reason::SecurityAlert])]);
2542+ let invitation = event("workspace_invitation.declined", None, json!({ "thread": "invitation:inv_1" }));
2543+ assert_eq!(settles(&invitation), vec![Settle::thread("invitation:inv_1").because(&[Reason::ReviewRequested])]);
2544+ }
2545+
2546+ #[test]
2547+ fn your_own_comment_settles_your_item_on_the_thread() {
2548+ let commented = event("comment.created", Some("usr_bo"), json!({ "repoId": "rep_1", "number": 7, "commentId": "cmt_1" }));
2549+ let by_bo = comment(person("usr_bo", "Bo"), "Looks good", &[]);
2550+ assert_eq!(handled_by(&commented, Some(&by_bo)), Some(Settle::thread("rep_1#7").of("bo")));
2551+ // Not g1t's, nor an agent's, nor a note the service wrote.
2552+ let by_g1t = comment(person(AGENT_ID, "g1t"), "Done", &[]);
2553+ assert_eq!(handled_by(&commented, Some(&by_g1t)), None);
2554+ let mut note = comment(person("usr_bo", "bo"), "", &[]);
2555+ note.comment.as_mut().unwrap().event = true;
2556+ assert_eq!(handled_by(&commented, Some(&note)), None);
2557+ assert_eq!(handled_by(&event("pull.merged", None, json!({ "repoId": "rep_1", "number": 7 })), Some(&by_bo)), None);
2558+ }
2559+
2560+ #[test]
22202561 fn a_deployments_reviewers_are_told() {
22212562 let asked = event(
22222563 "deployment.review_requested",
23902731 let thread = thread_of(&failed, &wants(&failed).unwrap(), None);
23912732 assert_eq!(thread.key, "rep_1/deploy/prj_1/production");
23922733 assert_eq!(url(Some("acme/rocket"), thread.kind, None, None, thread.link.as_deref()), "/acme/rocket/deployments/dpl_1");
2393− // A success is news to the owner only after a failure.
2734+ // A success tells the owner nothing, even after a failure: it
2735+ // settles the failure (settles). Watchers who chose deployments hear of it.
23942736 let live = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "commit": "abcdef0123" }));
23952737 assert!(notices(&live, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody()).is_empty());
23962738 let recovered = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "recovered": true }));
2397− let notices_ = notices(&recovered, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody());
2398− assert_eq!(told(&notices_), vec![("bo", Reason::CiActivity, Severity::Success)]);
2399− assert_eq!(notices_[0].title, "Production of rocket is live again");
2739+ assert!(notices(&recovered, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody()).is_empty());
2740+ let notices_ = notices(&recovered, "acme/rocket", &actor("usr_bo", "bo"), None, &audience);
2741+ assert_eq!(told(&notices_), vec![("cy", Reason::Subscribed, Severity::Success), ("eve", Reason::Subscribed, Severity::Success)]);
2742+ assert_eq!(notices_[0].title, "Production of rocket is live");
24002743 // A preview's is the pull request's owner's.
24012744 let preview = event("deployment.failed", None, json!({ "repoId": "rep_1", "projectId": "prj_1", "number": 7, "triggeredBy": "g1t" }));
24022745 assert_eq!(told(&notices(&preview, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())), vec![("ana", Reason::CiActivity, Severity::Error)]);
+7−1
116116 Step::Merge
117117 }
118118
119+/// Why g1t turns auto-merge off when the merge queue takes the pull
120+/// request: the queue lands it, so `pull.auto_merge_disabled` says
121+/// `queued` and tells nobody's inbox.
122+pub(crate) const JOINED_QUEUE: &str = "It joined the merge queue.";
123+
119124 /// Whether a push by `pusher` (a user id) turns auto-merge off: anyone's
120125 /// but whoever turned it on. A push g1t made itself, with no one behind
121126 /// it, does not.
157162 let event = PullEvent {
158163 detail: reason.map(str::to_owned),
159164 auto_merge_by: pull.auto_merge.as_ref().map(|auto| auto.enabled_by.clone()),
165+ queued: by.is_none() && reason == Some(JOINED_QUEUE),
160166 ..Self::pull_event(pull)
161167 };
162168 match by {
355361 Outcome::Ok(merged) => {
356362 if merged.status == PullStatus::Open && self.queued_entry(&pull.id).await?.is_some() {
357363 // In the merge queue it lands as the queue decides.
358− self.disable_auto_merge(&pull, None, Some("It joined the merge queue.")).await?;
364+ self.disable_auto_merge(&pull, None, Some(JOINED_QUEUE)).await?;
359365 }
360366 }
361367 Outcome::Fail(failure) => {
+7−3
26422642 .find(|issue| issue.repo_id == pull.repo_id && issue.number == number)
26432643 .cloned()
26442644 });
2645− // Only a pull request g1t is seeing through has a lifecycle.
2646− let lifecycle = if !lifecycle::made_by_g1t(&pull) || snapshot.managed == 0 {
2645+ // Every open pull request has a lifecycle (lifecycle.rs), kept
2646+ // fresh by what moves it (`advance`, on each event that can);
2647+ // anyone's draft but g1t's has none.
2648+ let managed = lifecycle::made_by_g1t(&pull) && snapshot.managed != 0;
2649+ let lifecycle = if pull.status == PullStatus::Draft && !managed {
26472650 None
26482651 } else if let (Some(stage), Some(detail)) = (snapshot.stage, snapshot.stage_detail) {
26492652 Some(Lifecycle {
26522655 revisions: snapshot.revisions,
26532656 })
26542657 } else {
2655− let behind = self.is_behind(&pull).await?;
2658+ // Never worked out yet: once, and remembered.
2659+ let behind = self.behind_now(&pull).await?;
26562660 self.assess(&pull, &issue, behind)
26572661 .await?
26582662 .map(|(lifecycle, _)| lifecycle)
+30−3
337337 }))
338338 }
339339
340+ /// Forgets where a repository's open pull requests stand, after its
341+ /// settings or rulesets changed what they need: each is worked out
342+ /// again the next time a list or its page shows it, or something moves it.
343+ pub(crate) async fn forget_stages(&self, repo_id: &str) -> Result<()> {
344+ self.db
345+ .prepare("UPDATE pulls SET stage = NULL, stage_detail = NULL WHERE repo_id = ? AND status IN ('draft', 'open') AND stage IS NOT NULL")
346+ .bind(&[repo_id.into()])?
347+ .run()
348+ .await?;
349+ Ok(())
350+ }
351+
340352 /// Saves where a pull request stands.
341353 pub(crate) async fn remember(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<()> {
342354 let stage = serde_json::to_value(lifecycle.stage)?;
499511 if pull.status != PullStatus::Open {
500512 return Ok(Advance::None);
501513 }
502− // A person asked for it to merge on its own (automerge.rs).
514+ // A person asked for it to merge on its own (automerge.rs). Where
515+ // it stands is remembered for lists all the same, if it is still open.
503516 if pull.auto_merge.is_some() {
504517 self.try_auto_merge(&pull).await?;
518+ if let Some(pull) = self.pull_by_id(&a.pull_id).await?
519+ && pull.status == PullStatus::Open
520+ {
521+ let behind = self.behind_now(&pull).await?;
522+ self.assess(&pull, &None, behind).await?;
523+ }
505524 return Ok(Advance::None);
506525 }
507526 // As a member: a private repository would look missing otherwise,
519538 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
520539 return Ok(Advance::None);
521540 };
522− let behind = self.is_behind(&pull).await?;
541+ // Asked of repos where g1t may merge it by itself; for anyone
542+ // else's, as stored with its mergeability for its head, which is
543+ // only shown (a merge asks repos again).
544+ let behind = if made_by_g1t(&pull) { self.is_behind(&pull).await? } else { self.behind_now(&pull).await? };
523545 if let Some((_, Next::Merge)) = self.assess(&pull, &None, behind).await? {
524546 self.merge_by_policy(&repo, &pull).await?;
525547 }
880902 Ok(())
881903 }
882904
905+ /// The open pull requests to move along: those g1t merges by itself or
906+ /// a person set to auto-merge, and, in one repository (after a merge
907+ /// there moved the branch they land on), every one whose lifecycle is
908+ /// remembered, so that lists show where each stands now.
883909 pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> {
884910 let rows = self
885911 .db
886912 .prepare(
887913 "SELECT id AS value FROM pulls
888− WHERE status = 'open' AND ((managed = 1 AND stalled IS NULL) OR auto_merge IS NOT NULL)
914+ WHERE status = 'open'
915+ AND ((managed = 1 AND stalled IS NULL) OR auto_merge IS NOT NULL OR (?1 IS NOT NULL AND stage IS NOT NULL))
889916 AND (?1 IS NULL OR repo_id = ?1)
890917 ORDER BY updated_at DESC LIMIT ?2",
891918 )
+7−0
529529 updated_at: now,
530530 };
531531 self.store_ruleset(&ruleset).await?;
532+ // What its pull requests need may have changed with it.
533+ if let Some(repo_id) = &ruleset.repo_id {
534+ self.forget_stages(repo_id).await?;
535+ }
532536 let kind = if before.is_some() { "ruleset.updated" } else { "ruleset.created" };
533537 self.announce(kind, &ruleset, &a.actor).await;
534538 if !a.from_api {
573577 return Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found."));
574578 };
575579 self.db.prepare("DELETE FROM rulesets WHERE id = ?").bind(&[ruleset.id.as_str().into()])?.run().await?;
580+ if let Some(repo_id) = &ruleset.repo_id {
581+ self.forget_stages(repo_id).await?;
582+ }
576583 self.announce("ruleset.deleted", &ruleset, &a.actor).await;
577584 if !a.from_api {
578585 self.audit_ruleset(&a.actor, &ruleset, "delete_ruleset").await;
+2−0
281281 if before.allow_auto_merge && !settings.allow_auto_merge {
282282 self.auto_merge_turned_off(&repo, &a.actor).await?;
283283 }
284+ // What its pull requests need may have changed with it.
285+ self.forget_stages(&repo.id).await?;
284286 Ok(Outcome::Ok(self.default_branch_settings(&repo).await?))
285287 }
286288

Comments

Sign in to comment.