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
21 files+649−1080/21 viewed
| 26 | 26 | ||
| 27 | 27 | | Source | Rows | | |
| 28 | 28 | | --- | --- | | |
| 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 | | |
| 31 | 31 | | Chat | A conversation that mentions you, or a direct message you haven't read. Muted conversations are left out | | |
| 32 | 32 | ||
| 33 | 33 | A notification about something already on the list, such as a pull | |
| ⋯ | |||
| 37 | 37 | waited, longest first: | |
| 38 | 38 | ||
| 39 | 39 | 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 | |
| 41 | 42 | 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. | |
| 45 | 46 | ||
| 46 | 47 | The list shows the first eight rows; **Show more** opens the rest. The | |
| 47 | 48 | sentence under the page's heading starts with how many things need you. | |
| 52 | 52 | | `assign` | assigned | You were assigned, or you are an assignee. | | |
| 53 | 53 | | `mention` | mentioned | Someone mentioned you with `@username`, or you were mentioned on it before. | | |
| 54 | 54 | | `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. | | |
| 56 | 56 | | `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. | | |
| 58 | 58 | | `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. | | |
| 59 | 59 | | `comment` | commented | You commented on it. | | |
| 60 | 60 | | `manual` | subscribed | You subscribed to it yourself. | | |
| ⋯ | |||
| 77 | 77 | | A workflow failed on a branch | Whoever pushed the commit it ran on | `ci_activity` | Error | | |
| 78 | 78 | | 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 | | |
| 79 | 79 | | 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 | | |
| 83 | 84 | | Someone mentioned you with `@username` in a comment | You | `mention` | Info | | |
| 84 | 85 | | 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 | | |
| 85 | 86 | | 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 | | |
| ⋯ | |||
| 89 | 90 | request for changes always reaches that person. A mention in code or in a | |
| 90 | 91 | quoted line does not count. | |
| 91 | 92 | ||
| 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 | − | ||
| 95 | 93 | You only see items about repositories you can read. If you lose access to a | |
| 96 | 94 | repository, its items leave your notifications the next time you open them. | |
| 97 | 95 | ||
| 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 | + | ||
| 98 | 123 | ## Subscriptions | |
| 99 | 124 | ||
| 100 | 125 | You are **subscribed** to an issue or pull request, and hear of what | |
| 422 | 422 | so a push nobody reviewed never lands by itself. Turning it on and off | |
| 423 | 423 | publishes `pull.auto_merge_enabled` and `pull.auto_merge_disabled`. | |
| 424 | 424 | ||
| 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 | + | ||
| 425 | 431 | ### Closing issues | |
| 426 | 432 | ||
| 427 | 433 | When a pull request merges into the default branch, it closes the issue it |
| 93 | 93 | | `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`. | | |
| 94 | 94 | | `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. | | |
| 95 | 95 | | `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. | | |
| 97 | 97 | | `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. | | |
| 98 | 98 | | `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. | | |
| 99 | 99 | | `pull.assigned` | People were assigned to a pull request. `data.assignees` is everyone assigned now, `data.added` those newly assigned. | |
| 132 | 132 | pull_blocked: <GitPullRequest size={16} className="text-warn" />, | |
| 133 | 133 | review: <GitPullRequest size={16} className="text-info" />, | |
| 134 | 134 | invitation: <Mail size={16} className="text-info" />, | |
| 135 | + | ready: <GitMerge size={16} className="text-success" />, | |
| 135 | 136 | runner: <Server size={16} className="text-warn" />, | |
| 136 | 137 | notification: <Bell size={16} className="text-warn" />, | |
| 137 | 138 | chat: <AtSign size={16} className="text-accent" />, |
| 59 | 59 | stalled: "border-warn/35 bg-warn/10 text-warn", | |
| 60 | 60 | invited: "border-accent/35 bg-accent/10 text-accent", | |
| 61 | 61 | needs_review: "border-info/35 bg-info/10 text-info", | |
| 62 | + | ready: "border-success/35 bg-success/10 text-success", | |
| 62 | 63 | }; | |
| 63 | 64 | ||
| 64 | 65 | function Chip({ children, tone }: { children: ReactNode; tone: string }) { |
| 80 | 80 | assert.equal(deploy.stake?.text, "Production on an older build"); | |
| 81 | 81 | assert.equal(codeRow(need({ key: "review:p2", reason: "needs_review", facts: [{ label: "Lines", value: "+10 −2", tone: null }] })).stake?.text, "+10 −2"); | |
| 82 | 82 | 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"); | |
| 83 | 92 | }); | |
| 84 | 93 | ||
| 85 | 94 | const row = (over: Partial<AttentionRow>): AttentionRow => ({ |
| 730 | 730 | * What a row is. Tiers, most pressing first: | |
| 731 | 731 | * 0. production is down to an older build (`deploy`); | |
| 732 | 732 | * 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); | |
| 734 | 735 | * 3. a job waiting for a runner that is not there; | |
| 735 | 736 | * 4. an unread notification that is a warning or a failure; | |
| 736 | 737 | * 5. a mention or a direct message. | |
| 737 | 738 | */ | |
| 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"; | |
| 739 | 740 | ||
| 740 | 741 | export const TIER: Record<AttentionKind, number> = { | |
| 741 | 742 | deploy: 0, | |
| 742 | 743 | pull_blocked: 1, | |
| 743 | 744 | review: 2, | |
| 744 | 745 | invitation: 2, | |
| 746 | + | ready: 2, | |
| 745 | 747 | runner: 3, | |
| 746 | 748 | notification: 4, | |
| 747 | 749 | chat: 5, | |
| ⋯ | |||
| 801 | 803 | pull_blocked: "This change can't land until someone fixes it.", | |
| 802 | 804 | review: "You were asked by name, so it waits for your verdict.", | |
| 803 | 805 | 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.", | |
| 804 | 807 | runner: "A job is waiting for a runner that is not there.", | |
| 805 | 808 | notification: "It is the most pressing unread notification.", | |
| 806 | 809 | chat: "Nothing else is waiting, and this is the oldest message for you.", | |
| ⋯ | |||
| 854 | 857 | } | |
| 855 | 858 | case "runner": | |
| 856 | 859 | 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 } }; | |
| 857 | 863 | default: | |
| 858 | 864 | return { | |
| 859 | 865 | ...base, | |
| 197 | 197 | ||
| 198 | 198 | const activeList = (active ?? []).flatMap((item) => { | |
| 199 | 199 | const repo = known.get(item.pull.repoId); | |
| 200 | − | return repo ? [{ pull: item.pull, repo }] : []; | |
| 200 | + | return repo ? [{ pull: item.pull, lifecycle: item.lifecycle, repo }] : []; | |
| 201 | 201 | }); | |
| 202 | 202 | const overviewList = okOr(overview ?? null); | |
| 203 | 203 | const projectsOk = okOr(projectList ?? null); | |
| ⋯ | |||
| 285 | 285 | }); | |
| 286 | 286 | } | |
| 287 | 287 | } | |
| 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 | + | } | |
| 289 | 312 | // 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; | |
| 291 | 314 | const key = `pull:${pull.id}`; | |
| 292 | 315 | needs.push({ | |
| 293 | 316 | key, | |
| 37 | 37 | assert.equal(reasonFor({ kind: "invitation" }), "invited"); | |
| 38 | 38 | assert.equal(reasonFor({ kind: "checks" }), "checks_failing"); | |
| 39 | 39 | assert.equal(reasonFor({ kind: "runner" }), "stalled"); | |
| 40 | + | // Ready to merge is not a review asked of anyone. | |
| 41 | + | assert.equal(reasonFor({ kind: "ready" }), "ready"); | |
| 40 | 42 | }); | |
| 41 | 43 | ||
| 42 | 44 | test("why a need needs a person is said for every reason", () => { |
| 18 | 18 | // --- Reasons ---------------------------------------------------------------- | |
| 19 | 19 | ||
| 20 | 20 | /** 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"; | |
| 22 | 22 | ||
| 23 | 23 | export const REASON_LABEL: Record<Reason, string> = { | |
| 24 | 24 | blocking: "Blocking", | |
| ⋯ | |||
| 26 | 26 | needs_review: "Review requested", | |
| 27 | 27 | checks_failing: "Checks failing", | |
| 28 | 28 | stalled: "Stalled", | |
| 29 | + | ready: "Ready to merge", | |
| 29 | 30 | }; | |
| 30 | 31 | ||
| 31 | 32 | /** The reason a need is shown, from its kind. */ | |
| ⋯ | |||
| 37 | 38 | case "invitation": | |
| 38 | 39 | return "invited"; | |
| 39 | 40 | case "review": | |
| 41 | + | return "needs_review"; | |
| 40 | 42 | case "ready": | |
| 41 | − | return "needs_review"; | |
| 43 | + | return "ready"; | |
| 42 | 44 | case "checks": | |
| 43 | 45 | return "checks_failing"; | |
| 44 | 46 | case "stalled": | |
| ⋯ | |||
| 67 | 69 | return "It is addressed to you."; | |
| 68 | 70 | case "stalled": | |
| 69 | 71 | 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."; | |
| 70 | 74 | } | |
| 71 | 75 | } | |
| 72 | 76 | ||
| 256 | 256 | /// auto-merge on, who is told it is off. | |
| 257 | 257 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 258 | 258 | 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, | |
| 259 | 264 | /// On `pull.labeled` and `pull.unlabeled`: the label put on or taken off. | |
| 260 | 265 | #[serde(skip_serializing_if = "Option::is_none")] | |
| 261 | 266 | pub label: Option<EventLabel>, |
| 1565 | 1565 | pub by: Option<String>, | |
| 1566 | 1566 | } | |
| 1567 | 1567 | ||
| 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>`. | |
| 1570 | 1573 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 1571 | 1574 | #[serde(rename_all = "camelCase")] | |
| 1572 | 1575 | pub struct ManagedPullsArgs { |
| 888 | 888 | * however often this is called. For the runner service. | |
| 889 | 889 | */ | |
| 890 | 890 | 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 | + | */ | |
| 892 | 896 | managedPulls(repoId?: string): Promise<string[]>; | |
| 893 | 897 | ||
| 894 | 898 | /** A repository's merge queue: what is in it, in order, and what recently left. */ |
| 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.'; |
| 15 | 15 | //! | `checks.completed`, failed or errored | the pull request's owner | ci_activity | error | | |
| 16 | 16 | //! | `workflow.completed`, failed | the pull request's owner, or whoever pushed | ci_activity | error | | |
| 17 | 17 | //! | `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 | | |
| 19 | 20 | //! | `pull.merged`, `pull.closed`, `issue.closed`, `issue.reopened` | everyone subscribed | state_change | success for a merge, else info | | |
| 20 | 21 | //! | `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) | | |
| 21 | 22 | //! | `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 | | |
| ⋯ | |||
| 25 | 26 | //! | `security_advisory.reported` | the workspace's owners and security managers | security_alert | warning | | |
| 26 | 27 | //! | `secret_scanning.bypass_requested`, `.bypass_reviewed` | the reviewers named; the requester | security_alert | warning; info | | |
| 27 | 28 | //! | |
| 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 | + | //! | |
| 28 | 34 | //! Watchers of a repository at `all` (or `custom`, for the kinds they | |
| 29 | 35 | //! chose) hear of every issue and pull request opened, commented on, | |
| 30 | 36 | //! closed, reopened or merged, and of deployments. Anyone who ignores the | |
| ⋯ | |||
| 187 | 193 | format!("{repo_id}#{number}") | |
| 188 | 194 | } | |
| 189 | 195 | ||
| 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 | + | ||
| 190 | 212 | /// The thread an event's items go to. | |
| 191 | 213 | pub fn thread_of(event: &Event, wanted: &Wanted, subject: Option<&InboxSubject>) -> Thread { | |
| 192 | 214 | let data = &event.data; | |
| ⋯ | |||
| 194 | 216 | match event.kind.as_str() { | |
| 195 | 217 | // A project's production, or one pull request's preview. | |
| 196 | 218 | "deployment.failed" | "deployment.succeeded" => { | |
| 197 | − | let which = wanted.number.map_or_else(|| "production".to_owned(), |number| number.to_string()); | |
| 198 | 219 | Thread { | |
| 199 | − | key: format!("{}/deploy/{}/{which}", wanted.repo_id, text("projectId").unwrap_or_default()), | |
| 220 | + | key: deploy_thread(&wanted.repo_id, data, wanted.number), | |
| 200 | 221 | kind: Some(SubjectKind::Deploy), | |
| 201 | 222 | number: wanted.number, | |
| 202 | 223 | run_id: text("deploymentId"), | |
| ⋯ | |||
| 218 | 239 | }, | |
| 219 | 240 | // A workflow on a branch: its next failure bumps the same thread. | |
| 220 | 241 | "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(); | |
| 224 | 242 | Thread { | |
| 225 | − | key: format!("{}/run/{workflow}@{branch}", wanted.repo_id), | |
| 243 | + | key: run_thread(&wanted.repo_id, data), | |
| 226 | 244 | kind: Some(SubjectKind::Run), | |
| 227 | 245 | number: None, | |
| 228 | 246 | run_id: text("runId"), | |
| ⋯ | |||
| 273 | 291 | } | |
| 274 | 292 | } | |
| 275 | 293 | ||
| 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) | |
| 281 | 399 | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | /// The issue or pull request an event names, as its thread. | |
| 403 | + | fn numbered(event: &Event) -> Option<String> { | |
| 282 | 404 | let repo_id = event.data["repoId"].as_str().map(str::to_owned).or_else(|| event.repo_id.clone())?; | |
| 283 | 405 | let number = event.data["number"].as_u64().and_then(|n| u32::try_from(n).ok())?; | |
| 284 | 406 | Some(numbered_thread(&repo_id, number)) | |
| 285 | 407 | } | |
| 286 | 408 | ||
| 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 | + | ||
| 287 | 523 | /// Collects who is told, each once with the most specific reason, never | |
| 288 | 524 | /// the actor, never g1t, and never anyone ignoring the thread. | |
| 289 | 525 | struct Told<'a> { | |
| ⋯ | |||
| 437 | 673 | } | |
| 438 | 674 | // Whoever turned auto-merge on hears that it is off, unless they | |
| 439 | 675 | // 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. | |
| 440 | 678 | ("pull.auto_merge_disabled", Some(pull)) => { | |
| 679 | + | if data["queued"].as_bool() == Some(true) { | |
| 680 | + | return told.notices; | |
| 681 | + | } | |
| 441 | 682 | if let Some(by) = data["autoMergeBy"].as_str() { | |
| 442 | 683 | let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty()); | |
| 443 | 684 | let title = format!("Auto-merge was turned off on {at}"); | |
| ⋯ | |||
| 539 | 780 | None => format!("Commit {}", short(data["commit"].as_str().unwrap_or_default())), | |
| 540 | 781 | }, | |
| 541 | 782 | }; | |
| 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 { | |
| 545 | 787 | match subject { | |
| 546 | 788 | Some(pull) => told.tell_person(pull.owner(), Reason::CiActivity, severity, &title, &body, true), | |
| 547 | 789 | None => { | |
| ⋯ | |||
| 961 | 1203 | repo_id: String, | |
| 962 | 1204 | url: String, | |
| 963 | 1205 | event_id: String, | |
| 1206 | + | /// Its event leaves nothing to do (`CLOSES`): settled once written. | |
| 1207 | + | closing: bool, | |
| 964 | 1208 | } | |
| 965 | 1209 | ||
| 966 | 1210 | /// Writes the items a batch from the bus calls for. Never fails the batch: | |
| 967 | 1211 | /// what cannot be worked out is logged and left. | |
| 968 | 1212 | 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 | + | } | |
| 971 | 1230 | } | |
| 1231 | + | } | |
| 1232 | + | ||
| 1233 | + | async fn deliver_events(db: &D1Database, sources: &Sources<'_>, events: &[Event], settled: &mut HashSet<String>) { | |
| 972 | 1234 | // A workspace's own notices, about no repository (workspace_notices). | |
| 973 | 1235 | for event in events.iter().filter(|event| WORKSPACE_EVENTS.contains(&event.kind.as_str())) { | |
| 974 | 1236 | if let Err(error) = deliver_workspace(db, event).await { | |
| ⋯ | |||
| 1021 | 1283 | let mut watchers: HashMap<String, Vec<subscriptions::Watcher>> = HashMap::new(); | |
| 1022 | 1284 | let mut written: Vec<Written> = Vec::new(); | |
| 1023 | 1285 | 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 { | |
| 1025 | 1287 | Ok(mut news) => written.append(&mut news), | |
| 1026 | 1288 | Err(error) => worker::console_error!("inbox: {} {} not delivered: {error}", event.kind, event.id), | |
| 1027 | 1289 | } | |
| 1028 | 1290 | } | |
| 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. | |
| 1030 | 1294 | if let Some(notify) = sources.notify { | |
| 1031 | 1295 | let live = written | |
| 1032 | 1296 | .iter() | |
| 1297 | + | .filter(|item| !item.closing) | |
| 1033 | 1298 | .map(|item| (item.notice.username.clone(), live_notification(&item.event_id, &item.notice, &item.url, &rfc3339(now_ms())))) | |
| 1034 | 1299 | .collect::<Vec<_>>(); | |
| 1035 | 1300 | 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(); | |
| 1037 | 1302 | 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 | + | } | |
| 1039 | 1306 | } | |
| 1040 | 1307 | } | |
| 1041 | 1308 | if let Err(error) = email(db, sources.identity, written).await { | |
| ⋯ | |||
| 1151 | 1418 | Ok(()) | |
| 1152 | 1419 | } | |
| 1153 | 1420 | ||
| 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 | + | } | |
| 1156 | 1427 | 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, | |
| 1166 | 1432 | } | |
| 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)); | |
| 1179 | 1436 | } | |
| 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) | |
| 1206 | 1438 | } | |
| 1207 | 1439 | ||
| 1440 | + | #[allow(clippy::too_many_arguments)] | |
| 1208 | 1441 | async fn deliver_one( | |
| 1209 | 1442 | db: &D1Database, | |
| 1210 | 1443 | sources: &Sources<'_>, | |
| ⋯ | |||
| 1213 | 1446 | watchers: &mut HashMap<String, Vec<subscriptions::Watcher>>, | |
| 1214 | 1447 | event: &Event, | |
| 1215 | 1448 | wanted: Wanted, | |
| 1449 | + | settled: &mut HashSet<String>, | |
| 1216 | 1450 | ) -> Result<Vec<Written>> { | |
| 1217 | 1451 | if !paths.contains_key(&wanted.repo_id) { | |
| 1218 | 1452 | let path: Option<RepoPath> = g1t_kit::call( | |
| ⋯ | |||
| 1257 | 1491 | }, | |
| 1258 | 1492 | }; | |
| 1259 | 1493 | 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 | + | } | |
| 1260 | 1498 | if !watchers.contains_key(&wanted.repo_id) { | |
| 1261 | 1499 | watchers.insert(wanted.repo_id.clone(), subscriptions::watchers(db, &wanted.repo_id).await?); | |
| 1262 | 1500 | } | |
| ⋯ | |||
| 1383 | 1621 | repo_id: wanted.repo_id.clone(), | |
| 1384 | 1622 | url: item_url.clone(), | |
| 1385 | 1623 | event_id: event.id.clone(), | |
| 1624 | + | closing: CLOSES.contains(&event.kind.as_str()), | |
| 1386 | 1625 | }) | |
| 1387 | 1626 | .collect()) | |
| 1388 | 1627 | } | |
| ⋯ | |||
| 2209 | 2448 | } | |
| 2210 | 2449 | ||
| 2211 | 2450 | #[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] | |
| 2212 | 2461 | fn whatever_an_agent_waits_on_closes_when_it_goes_on() { | |
| 2462 | + | let agent = Settle::thread("rep_1#7").because(&[Reason::Agent]); | |
| 2213 | 2463 | 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}"); | |
| 2215 | 2465 | } | |
| 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()); | |
| 2217 | 2481 | } | |
| 2218 | 2482 | ||
| 2219 | 2483 | #[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(¬e)), None); | |
| 2557 | + | assert_eq!(handled_by(&event("pull.merged", None, json!({ "repoId": "rep_1", "number": 7 })), Some(&by_bo)), None); | |
| 2558 | + | } | |
| 2559 | + | ||
| 2560 | + | #[test] | |
| 2220 | 2561 | fn a_deployments_reviewers_are_told() { | |
| 2221 | 2562 | let asked = event( | |
| 2222 | 2563 | "deployment.review_requested", | |
| ⋯ | |||
| 2390 | 2731 | let thread = thread_of(&failed, &wants(&failed).unwrap(), None); | |
| 2391 | 2732 | assert_eq!(thread.key, "rep_1/deploy/prj_1/production"); | |
| 2392 | 2733 | 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. | |
| 2394 | 2736 | let live = event("deployment.succeeded", Some("usr_bo"), json!({ "repoId": "rep_1", "projectId": "prj_1", "project": "rocket", "commit": "abcdef0123" })); | |
| 2395 | 2737 | assert!(notices(&live, "acme/rocket", &actor("usr_bo", "bo"), None, &nobody()).is_empty()); | |
| 2396 | 2738 | 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(¬ices_), 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(¬ices_), vec![("cy", Reason::Subscribed, Severity::Success), ("eve", Reason::Subscribed, Severity::Success)]); | |
| 2742 | + | assert_eq!(notices_[0].title, "Production of rocket is live"); | |
| 2400 | 2743 | // A preview's is the pull request's owner's. | |
| 2401 | 2744 | let preview = event("deployment.failed", None, json!({ "repoId": "rep_1", "projectId": "prj_1", "number": 7, "triggeredBy": "g1t" })); | |
| 2402 | 2745 | assert_eq!(told(¬ices(&preview, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())), vec![("ana", Reason::CiActivity, Severity::Error)]); | |
| 116 | 116 | Step::Merge | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 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 | + | ||
| 119 | 124 | /// Whether a push by `pusher` (a user id) turns auto-merge off: anyone's | |
| 120 | 125 | /// but whoever turned it on. A push g1t made itself, with no one behind | |
| 121 | 126 | /// it, does not. | |
| ⋯ | |||
| 157 | 162 | let event = PullEvent { | |
| 158 | 163 | detail: reason.map(str::to_owned), | |
| 159 | 164 | auto_merge_by: pull.auto_merge.as_ref().map(|auto| auto.enabled_by.clone()), | |
| 165 | + | queued: by.is_none() && reason == Some(JOINED_QUEUE), | |
| 160 | 166 | ..Self::pull_event(pull) | |
| 161 | 167 | }; | |
| 162 | 168 | match by { | |
| ⋯ | |||
| 355 | 361 | Outcome::Ok(merged) => { | |
| 356 | 362 | if merged.status == PullStatus::Open && self.queued_entry(&pull.id).await?.is_some() { | |
| 357 | 363 | // 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?; | |
| 359 | 365 | } | |
| 360 | 366 | } | |
| 361 | 367 | Outcome::Fail(failure) => { | |
| 2642 | 2642 | .find(|issue| issue.repo_id == pull.repo_id && issue.number == number) | |
| 2643 | 2643 | .cloned() | |
| 2644 | 2644 | }); | |
| 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 { | |
| 2647 | 2650 | None | |
| 2648 | 2651 | } else if let (Some(stage), Some(detail)) = (snapshot.stage, snapshot.stage_detail) { | |
| 2649 | 2652 | Some(Lifecycle { | |
| ⋯ | |||
| 2652 | 2655 | revisions: snapshot.revisions, | |
| 2653 | 2656 | }) | |
| 2654 | 2657 | } 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?; | |
| 2656 | 2660 | self.assess(&pull, &issue, behind) | |
| 2657 | 2661 | .await? | |
| 2658 | 2662 | .map(|(lifecycle, _)| lifecycle) | |
| 337 | 337 | })) | |
| 338 | 338 | } | |
| 339 | 339 | ||
| 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 | + | ||
| 340 | 352 | /// Saves where a pull request stands. | |
| 341 | 353 | pub(crate) async fn remember(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<()> { | |
| 342 | 354 | let stage = serde_json::to_value(lifecycle.stage)?; | |
| ⋯ | |||
| 499 | 511 | if pull.status != PullStatus::Open { | |
| 500 | 512 | return Ok(Advance::None); | |
| 501 | 513 | } | |
| 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. | |
| 503 | 516 | if pull.auto_merge.is_some() { | |
| 504 | 517 | 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 | + | } | |
| 505 | 524 | return Ok(Advance::None); | |
| 506 | 525 | } | |
| 507 | 526 | // As a member: a private repository would look missing otherwise, | |
| ⋯ | |||
| 519 | 538 | let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else { | |
| 520 | 539 | return Ok(Advance::None); | |
| 521 | 540 | }; | |
| 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? }; | |
| 523 | 545 | if let Some((_, Next::Merge)) = self.assess(&pull, &None, behind).await? { | |
| 524 | 546 | self.merge_by_policy(&repo, &pull).await?; | |
| 525 | 547 | } | |
| ⋯ | |||
| 880 | 902 | Ok(()) | |
| 881 | 903 | } | |
| 882 | 904 | ||
| 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. | |
| 883 | 909 | pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> { | |
| 884 | 910 | let rows = self | |
| 885 | 911 | .db | |
| 886 | 912 | .prepare( | |
| 887 | 913 | "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)) | |
| 889 | 916 | AND (?1 IS NULL OR repo_id = ?1) | |
| 890 | 917 | ORDER BY updated_at DESC LIMIT ?2", | |
| 891 | 918 | ) | |
| 529 | 529 | updated_at: now, | |
| 530 | 530 | }; | |
| 531 | 531 | 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 | + | } | |
| 532 | 536 | let kind = if before.is_some() { "ruleset.updated" } else { "ruleset.created" }; | |
| 533 | 537 | self.announce(kind, &ruleset, &a.actor).await; | |
| 534 | 538 | if !a.from_api { | |
| ⋯ | |||
| 573 | 577 | return Ok(Outcome::fail(FailureCode::NotFound, "Ruleset not found.")); | |
| 574 | 578 | }; | |
| 575 | 579 | 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 | + | } | |
| 576 | 583 | self.announce("ruleset.deleted", &ruleset, &a.actor).await; | |
| 577 | 584 | if !a.from_api { | |
| 578 | 585 | self.audit_ruleset(&a.actor, &ruleset, "delete_ruleset").await; | |
| 281 | 281 | if before.allow_auto_merge && !settings.allow_auto_merge { | |
| 282 | 282 | self.auto_merge_turned_off(&repo, &a.actor).await?; | |
| 283 | 283 | } | |
| 284 | + | // What its pull requests need may have changed with it. | |
| 285 | + | self.forget_stages(&repo.id).await?; | |
| 284 | 286 | Ok(Outcome::Ok(self.default_branch_settings(&repo).await?)) | |
| 285 | 287 | } | |
| 286 | 288 |
Comments
Sign in to comment.