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

Notifications settle themselves once nothing is left to do, and Home's Needs you shows your pull requests that are ready to merge or need you. Live today, Home's "From your notifications" and `g1t status` still listed "Deploy failed on main" (Run 327) and "CI failed on main" (Run 243) hours after later runs on main passed, "Reviews asked of you" listed flagon-io/g1t#39 after it merged, flagon-io/automation-lab#38 told its author "turned off auto-merge: It joined the merge queue" though the queue then merged it, and Needs you said 0 while #41 (CI passed, no approvals required) waited only for its author to merge it. The inbox only ever closed three things by itself (an agent's stall when it went on, a removed review request, an answered invitation); everything else stayed unread until marked by hand. Now the events service settles an item (marks it done and read, so it leaves the unread count, the bell, Needs you and `g1t status`, and stays in Done with its history) when the event that ends it arrives (services/events/src/inbox.rs, "Settling"): a workflow's failures on a branch when that workflow next passes there; a pull request's failed checks when checks.completed passes on its head; a deployment's failure when that production or preview deploys; a deployment waiting for review when its run ends; a review request when it is removed from you or your team; a token approval when an owner decides it; a bypass request when it is decided; your own item on a thread when you comment or review there; and everything about an issue or pull request, its previews included, once it merges or closes. A merge or close still reaches each thread's history but settles it at once, so nobody is pushed a notification that is already done. Two kinds are gone: "is live again" after a failed deployment (it settles the failure instead; watchers who chose deployments still hear of every deployment) and auto-merge turned off because the pull request joined the merge queue (work's pull.auto_merge_disabled now says `queued`, and the inbox tells nobody). Migration 0011 settles, once, items written before this whose cause the event log shows is over: run failures passed since, deployment failures deployed since, threads of issues and pull requests merged or closed since, and the merge-queue auto-merge notices. Each settled person's live count is told again. On the work side, every open pull request's remembered lifecycle is now shown in lists (list_active_pulls), not only those g1t's agent made: advance, which the runner calls on every event that can move one, remembers it for pull requests with auto-merge on too and reads whether one is behind as stored with its mergeability rather than asking repos (g1t's own still ask, since it merges them); after a merge, managed_pulls for that repository includes every open one with a remembered lifecycle; a settings or ruleset change forgets the repository's remembered lifecycles, so each is worked out again on its next showing. Home and Code's Overview turn your pull request's lifecycle into rows in the workspace: needs_you as Resolve (a conflict) or Decide, and ready as "Ready to land when you merge it." with Merge, a Ready to merge chip and its required checks shown passing. The notifications guide has a section on threads that settle themselves; the Home, pull requests (auto-merge) and webhooks guides say what changed. Checked: cargo test -p g1t-events (64, 7 of them new, for the settling rules), -p g1t-work (189), -p g1t-contracts (312), -p g1t-api the_docs_copy_is_current; clippy adds no warning; the site's tsc -b --force and unit tests (934); the docs build; migration 0011 against sample rows in SQLite.

G1t-Session:
ses_01m4y12gdde2r9q832m7xk87kd
syntaqxcommitted Parent6e24edfBrowse 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.