Merge branch 'worktree-agent-aa8485640dce3cc93' into ship-batch
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.