The Work board reads deleted repositories, merged pull requests and its caps right #164
Mergeds
syntaqx merged board-fixes into main
39 files+686−2197 files not counted0/39 viewed
| 5316 | 5316 | label: optional_text(input, "label"), | |
| 5317 | 5317 | milestone: integer(input, "milestone"), | |
| 5318 | 5318 | assignee: optional_text(input, "assignee"), | |
| 5319 | + | before: None, | |
| 5320 | + | closed_since: None, | |
| 5319 | 5321 | }, | |
| 5320 | 5322 | ) | |
| 5321 | 5323 | .await | |
| ⋯ | |||
| 5662 | 5664 | label: optional_text(input, "label"), | |
| 5663 | 5665 | milestone: integer(input, "milestone"), | |
| 5664 | 5666 | base: optional_text(input, "base"), | |
| 5667 | + | before: None, | |
| 5668 | + | merged_since: None, | |
| 5665 | 5669 | }, | |
| 5666 | 5670 | ) | |
| 5667 | 5671 | .await | |
| 161 | 161 | label: None, | |
| 162 | 162 | milestone: None, | |
| 163 | 163 | base: None, | |
| 164 | + | before: None, | |
| 165 | + | merged_since: None, | |
| 164 | 166 | }, | |
| 165 | 167 | ) | |
| 166 | 168 | .await?; |
| 48 | 48 | ||
| 49 | 49 | | Figure | What it counts | | |
| 50 | 50 | | --- | --- | | |
| 51 | − | | **Agent-authored changes** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. | | |
| 52 | − | | **Spend per agent change** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. | | |
| 51 | + | | **Agent-authored merges** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents). | | |
| 52 | + | | **Spend per agent merge** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent pull requests that merged in it: an average, not what any one cost. A dash when usage can't be read or nothing an agent made merged. | | |
| 53 | 53 | | **Stalled changes** | Pull requests open at the end of each week (now, for this one), opened three days or more before, with nothing in the three days before: no push, comment, review, timeline event, check or session. Drafts count. | | |
| 54 | − | | **Agent rework rate** | Of the agent pull requests that merged in the week (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. | | |
| 54 | + | | **Agent rework rate** | Of the agent pull requests that merged in the week, the share a person asked for changes on before they merged. | | |
| 55 | 55 | ||
| 56 | 56 | ### Whose work is an agent's | |
| 57 | 57 | ||
| ⋯ | |||
| 78 | 78 | | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. | | |
| 79 | 79 | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 80 | 80 | | **A practice catching on** | Two or more people started using a harness (Claude Code, Codex, Cursor and so on) this month, at least two weeks after the first person did. | | |
| 81 | − | | **Who works where** | In a workspace where three people or more merged lately, someone started merging to a project one person has carried (three quarters of its last 90 days' merges); or merged to a project for the first time this week after work elsewhere. | | |
| 81 | + | | **Who works where** | Someone started merging to a project one person has carried (three quarters of its last 90 days' merges), or merged to a project for the first time this week after work elsewhere. | | |
| 82 | 82 | ||
| 83 | 83 | Findings are ordered by how much they hold up: blocked work first, then | |
| 84 | 84 | waits, then what shipped and where spend went. Up to five show, and the | |
| 682 | 682 | figures as Home. | |
| 683 | 683 | 2. **Spend, threaded to the work**: the quarter's work in four columns: | |
| 684 | 684 | each milestone, its work items, each work item's change set in each | |
| 685 | − | project, and that change set's pull requests (with no milestones in | |
| 686 | − | the workspace, it starts at the work items). Each box is sized by | |
| 685 | + | project, and that change set's pull requests. Each box is sized by | |
| 687 | 686 | what agents cost on its pull requests (g1t's metered usage, and each | |
| 688 | 687 | pull request's share of its sessions' cost estimated at list price), | |
| 689 | 688 | and shaded by how many were an agent's (**Agent use**, less to more). Choose a box to | |
| ⋯ | |||
| 695 | 694 | organization ran, so it is never added to g1t's. | |
| 696 | 695 | 3. **What's moving and what's waiting**: each team's last two weeks, day | |
| 697 | 696 | by day, by the projects the team works on (each project, when the | |
| 698 | − | workspace has no teams). Above the line are changes (pull requests | |
| 699 | − | opened and merged, and commits agents pushed outside one) and agent | |
| 700 | − | sessions started; below it, reviews and comments and | |
| 697 | + | workspace has no teams). Above the line are pull requests opened and | |
| 698 | + | merged, and agent sessions started; below it, reviews and comments and | |
| 701 | 699 | requests for changes. Turn each layer on or off above the rows, point | |
| 702 | 700 | at a day for its numbers, or open **The numbers, as a table**. Each | |
| 703 | 701 | row is drawn to its own scale. Beside it, **Waiting on a first | |
| ⋯ | |||
| 705 | 703 | 4. **Quiet changes**: places to look, not verdicts. | |
| 706 | 704 | - **Knowledge silos**: projects where one person merged three quarters | |
| 707 | 705 | or more of the last 90 days' changes (six or more), and whoever has | |
| 708 | − | started there this month. Only in a workspace where three people or | |
| 709 | − | more merged lately: with fewer, every project rests on someone. | |
| 706 | + | started there this month. | |
| 710 | 707 | - **New ground**: people who merged to a project for the first time in | |
| 711 | 708 | the last two weeks, after three or more merges elsewhere. | |
| 712 | 709 | - **Spreading**: a harness two or more people in a team started using | |
| 13 | 13 | | Stage | Holds the work items | | |
| 14 | 14 | | --- | --- | | |
| 15 | 15 | | **Backlog** | Open, and nobody is on it. | | |
| 16 | − | | **In progress** | Claimed or assigned, with a live session on it, or with a draft pull request. | | |
| 16 | + | | **In progress** | Claimed or assigned, with a live session on it, with a draft pull request, or with a pull request merged that did not close it. | | |
| 17 | 17 | | **In review** | With a pull request open for review. | | |
| 18 | − | | **Landing** | Landed in some of its target repositories, still missing in others. | | |
| 19 | − | | **Done** | Closed in the last 14 days. | | |
| 18 | + | | **Landing** | Landed in some of its target repositories and still missing in others, or with a pull request that closes it merged while a target still misses it. | | |
| 19 | + | | **Done** | Closed as completed in the last 14 days. | | |
| 20 | 20 | ||
| 21 | 21 | A work item is in the first stage that holds for it, in the order | |
| 22 | 22 | **Done**, **Landing**, **In review**, **In progress**, **Backlog**. So a | |
| 23 | 23 | work item with one pull request in review and another still a draft is | |
| 24 | 24 | **In review**, and one that has landed in one of its two targets is | |
| 25 | 25 | **Landing** whatever else is going on. A work item closed more than 14 | |
| 26 | − | days ago leaves the Board; the Work list's **Closed** still has it. | |
| 26 | + | days ago leaves the Board, and so does one closed as not planned (a | |
| 27 | + | duplicate, say), since it did not ship. The Work list's **Closed** still | |
| 28 | + | has both. | |
| 29 | + | ||
| 30 | + | ## Which pull requests count | |
| 27 | 31 | ||
| 32 | + | A pull request counts for a work item when it is for that work item, | |
| 33 | + | when it closes it (`Fixes #12` in its title or description), or when it | |
| 34 | + | is part of it (`Part of #12`). The Board reads each repository's open | |
| 35 | + | and draft pull requests, and those merged in the last 14 days, in every | |
| 36 | + | repository an open work item is linked to. | |
| 37 | + | ||
| 38 | + | - **Merged, and it closes the work item**, but a target repository still | |
| 39 | + | misses it (it merged into a repository that is not a target): the work | |
| 40 | + | item is **Landing** until it lands in every target. | |
| 41 | + | - **Merged as part of it**, or merged with no target left to land in (for | |
| 42 | + | example into a branch that is not the default): the work item stays | |
| 43 | + | **In progress**, since there is more to do before it closes. | |
| 44 | + | - Its card says how many of its pull requests merged in the last 14 days, | |
| 45 | + | such as "1 merged". | |
| 46 | + | ||
| 28 | 47 | ## How a work item moves | |
| 29 | 48 | ||
| 30 | 49 | A typical work item goes left to right as the work happens: | |
| ⋯ | |||
| 37 | 56 | (`g1t pr create --item 12` opens a draft). A pull request a live session | |
| 38 | 57 | is on counts too. | |
| 39 | 58 | 3. **In review.** Its pull request is marked ready for review. | |
| 40 | − | 4. **Landing.** For a work item with several targets: its pull request | |
| 41 | − | merged in one repository and the others are still to land. A work item | |
| 42 | − | with no target, or one, skips this stage: it closes as soon as it lands. | |
| 43 | − | 5. **Done.** It closed: every target landed, or someone closed it. See | |
| 44 | − | [closing work items](/work/closing/). | |
| 59 | + | 4. **Landing.** For a work item with targets still to land: its pull | |
| 60 | + | request merged in one target and the others are still to land, or it | |
| 61 | + | merged into a repository that is not a target. A work item whose pull | |
| 62 | + | request lands in its only target, or one with no target, skips this | |
| 63 | + | stage: it closes as soon as it lands. | |
| 64 | + | 5. **Done.** It closed as completed: every target landed, or someone | |
| 65 | + | closed it. See [closing work items](/work/closing/). | |
| 45 | 66 | ||
| 46 | 67 | It moves back as well: a claim released or expired with nobody else on it | |
| 47 | 68 | and no pull request sends it back to **Backlog**, and reopening a work item | |
| ⋯ | |||
| 52 | 73 | ||
| 53 | 74 | Each column lists its work items most recently changed first, and **Done** | |
| 54 | 75 | most recently closed first. Each card shows the work item's title, number | |
| 55 | − | and repositories, and who is on it: up to three people (its assignees, | |
| 56 | − | whoever claimed it, whoever runs a session on it), up to two of its live | |
| 57 | − | sessions, then "+2" for the rest. Choose a card to open the work item. | |
| 76 | + | and repositories, how many of its pull requests merged lately, and who is | |
| 77 | + | on it: up to three people (its assignees, whoever claimed it, whoever runs | |
| 78 | + | a session on it) and up to two of its live sessions, each followed by a | |
| 79 | + | count of the rest, such as "+4" for seven people. A session with no title | |
| 80 | + | of its own is named by what it is on, such as "On pull request #158". | |
| 81 | + | Choose a card to open the work item. | |
| 82 | + | ||
| 83 | + | A repository that was deleted and can still be restored shows struck | |
| 84 | + | through on its cards, and the Board reads nothing from it. If a | |
| 85 | + | repository's pull requests could not be read at all, the Board says so | |
| 86 | + | above the columns: its work items may stand a column early until it can. | |
| 58 | 87 | ||
| 88 | + | The Board reads up to 1,000 open work items, the newest first, up to 300 | |
| 89 | + | open and 300 recently merged pull requests in each repository, and up to | |
| 90 | + | 100 work items for **Done**. When there are more, it says so above the | |
| 91 | + | columns, such as "Showing the newest 1,000 open work items." | |
| 92 | + | ||
| 59 | 93 | The Board follows the work as it happens, without reloading, and checks | |
| 60 | 94 | again every 30 seconds while a session is live, since a session going idle | |
| 61 | 95 | sends no event. | |
| ⋯ | |||
| 65 | 99 | - **On a phone** the Board shows one stage at a time, with a button for | |
| 66 | 100 | each and how many it holds; the address keeps it as `?stage=landing`. | |
| 67 | 101 | - **In the phone app**, **Board** at the top of Work shows the stages one | |
| 68 | − | under another, with a chip for each to show one alone. | |
| 102 | + | under another, with a chip for each to show one alone. It reads only | |
| 103 | + | open pull requests for now, so a work item whose pull request merged | |
| 104 | + | without closing it is where its claims and sessions put it. | |
| 69 | 105 | ||
| 70 | 106 | **New work item** is on the Board too. | |
| 57 | 57 | assert.equal(stageOf(item(1, { state: "closed", closed_at: new Date(NOW - 3 * DAY).toISOString() }), none), "done"); | |
| 58 | 58 | assert.equal(stageOf(item(1, { state: "closed", closed_at: new Date(NOW - 15 * DAY).toISOString() }), none), null); | |
| 59 | 59 | assert.equal(stageOf(item(1, { state: "closed", closed_at: null }), none), null); | |
| 60 | + | // Closed as not planned did not ship. | |
| 61 | + | assert.equal(stageOf(item(1, { state: "closed", reason: "not_planned", closed_at: new Date(NOW - DAY).toISOString() }), none), null); | |
| 60 | 62 | // Done wins over everything else. | |
| 61 | 63 | const repos = [target("acme/web", true), target("acme/api", false)]; | |
| 62 | 64 | assert.equal(stageOf(item(1, { state: "closed", repos, closed_at: new Date(NOW - DAY).toISOString() }), { ...none, pulls: [{ item: 1, status: "open" }] }), "done"); |
| 3 | 3 | ||
| 4 | 4 | /** | |
| 5 | 5 | * Work's board: where each work item stands as it moves, read from the | |
| 6 | − | * item, its pull requests and the sessions on it. Done is closed in the | |
| 7 | − | * last two weeks; Landing has landed in some targets but not all; In | |
| 6 | + | * item, its pull requests and the sessions on it. Done is closed as | |
| 7 | + | * completed in the last two weeks (not planned is left out); Landing has landed in some targets but not all; In | |
| 8 | 8 | * review has an open pull request; In progress has someone on it (an | |
| 9 | 9 | * assignee, a claim, a live session) or a draft; Backlog has nobody. | |
| 10 | 10 | * The first that holds, in that order, is its stage. | |
| ⋯ | |||
| 32 | 32 | /** How long a closed item stays in Done. */ | |
| 33 | 33 | export const DONE_DAYS = 14; | |
| 34 | 34 | ||
| 35 | − | export type BoardItem = Pick<ApiWorkItem, "number" | "state" | "assignees" | "repos" | "closed_at"> & { | |
| 36 | − | /** Someone has claimed it. */ | |
| 37 | − | claimed?: boolean; | |
| 38 | − | }; | |
| 35 | + | export type BoardItem = Pick<ApiWorkItem, "number" | "state" | "assignees" | "repos" | "closed_at"> & | |
| 36 | + | Partial<Pick<ApiWorkItem, "reason">> & { | |
| 37 | + | /** Someone has claimed it. */ | |
| 38 | + | claimed?: boolean; | |
| 39 | + | }; | |
| 39 | 40 | ||
| 40 | 41 | /** A pull request for a work item, by the item's number in the workspace. */ | |
| 41 | 42 | export type BoardPull = { item: number | null; status: PullStatus }; | |
| ⋯ | |||
| 57 | 58 | export function stageOf(item: BoardItem, context: BoardContext): Stage | null { | |
| 58 | 59 | const now = context.now ?? Date.now(); | |
| 59 | 60 | if (item.state === "closed") { | |
| 61 | + | // Closed as not planned (a duplicate, say) did not ship: not Done. | |
| 62 | + | if (item.reason === "not_planned") return null; | |
| 60 | 63 | const closed = item.closed_at ? Date.parse(item.closed_at) : NaN; | |
| 61 | 64 | return !Number.isNaN(closed) && now - closed <= DONE_DAYS * 86_400_000 ? "done" : null; | |
| 62 | 65 | } | |
| 1 | 1 | /** | |
| 2 | 2 | * What is moving and what is waiting (lib/signals.ts rhythm): each team's | |
| 3 | 3 | * last two weeks, day by day. Above the line, pull requests opened and | |
| 4 | − | * merged and commits agents pushed outside one (bars), and agent sessions | |
| 5 | − | * started (a wash); below it, reviews and | |
| 4 | + | * merged (bars) and agent sessions started (a wash); below it, reviews and | |
| 6 | 5 | * comments (a wash) and requests for changes (bars). Each row is drawn to | |
| 7 | 6 | * its own scale, so a small team's week is as legible as a big one's; the | |
| 8 | 7 | * numbers are in the readout and the table. Layers turn on and off above | |
| ⋯ | |||
| 17 | 16 | type Layer = "prs" | "rework" | "agent" | "reviews"; | |
| 18 | 17 | ||
| 19 | 18 | const LAYERS: { key: Layer; label: string; swatch: string }[] = [ | |
| 20 | − | { key: "prs", label: "Changes", swatch: "bg-fg-soft" }, | |
| 19 | + | { key: "prs", label: "PR activity", swatch: "bg-fg-soft" }, | |
| 21 | 20 | { key: "rework", label: "Rework", swatch: "bg-faint" }, | |
| 22 | 21 | { key: "agent", label: "Agent activity", swatch: "bg-series-1" }, | |
| 23 | 22 | { key: "reviews", label: "Reviews & comments", swatch: "bg-series-4" }, | |
| ⋯ | |||
| 55 | 54 | <ul className="mt-2 space-y-1 text-fg"> | |
| 56 | 55 | <li className="flex items-center gap-2"> | |
| 57 | 56 | <span className="size-2 rounded-full bg-fg-soft" /> | |
| 58 | − | {sum.opened} opened · {sum.merged} merged{sum.commits > 0 && ` · ${sum.commits} pushed by agents`} | |
| 57 | + | {sum.opened} opened · {sum.merged} merged | |
| 59 | 58 | </li> | |
| 60 | 59 | <li className="flex items-center gap-2"> | |
| 61 | 60 | <span className="size-2 rounded-full bg-faint" /> | |
| ⋯ | |||
| 90 | 89 | const n = row.days.length; | |
| 91 | 90 | const slot = WIDTH / n; | |
| 92 | 91 | const x = (i: number) => slot * i + slot / 2; | |
| 93 | − | const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged + day.commits : 0, on.has("agent") ? day.sessions : 0))); | |
| 92 | + | const up = Math.max(1, ...row.days.map((day) => Math.max(on.has("prs") ? day.opened + day.merged : 0, on.has("agent") ? day.sessions : 0))); | |
| 94 | 93 | const down = Math.max(1, ...row.days.map((day) => Math.max(on.has("rework") ? day.reworked : 0, on.has("reviews") ? day.reviews : 0))); | |
| 95 | 94 | const above = (value: number) => MID - (value / up) * (MID - 6); | |
| 96 | 95 | const below = (value: number) => MID + (value / down) * (MID - 6); | |
| ⋯ | |||
| 134 | 133 | <line x1={0} x2={WIDTH} y1={MID} y2={MID} className="stroke-line-strong" strokeWidth={1} vectorEffect="non-scaling-stroke" /> | |
| 135 | 134 | {on.has("prs") && | |
| 136 | 135 | row.days.map((d, i) => | |
| 137 | − | d.opened + d.merged + d.commits > 0 ? ( | |
| 136 | + | d.opened + d.merged > 0 ? ( | |
| 138 | 137 | <rect | |
| 139 | 138 | key={`p${i}`} | |
| 140 | 139 | x={x(i) - BAR / 2} | |
| 141 | − | y={above(d.opened + d.merged + d.commits)} | |
| 140 | + | y={above(d.opened + d.merged)} | |
| 142 | 141 | width={BAR} | |
| 143 | − | height={MID - above(d.opened + d.merged + d.commits)} | |
| 142 | + | height={MID - above(d.opened + d.merged)} | |
| 144 | 143 | rx={1.5} | |
| 145 | 144 | className="fill-fg-soft" | |
| 146 | 145 | /> | |
| ⋯ | |||
| 225 | 224 | <th className="py-1 font-normal">{by === "team" ? "Team" : "Project"}</th> | |
| 226 | 225 | <th className="py-1 font-normal">Opened</th> | |
| 227 | 226 | <th className="py-1 font-normal">Merged</th> | |
| 228 | − | <th className="py-1 font-normal">Pushed by agents</th> | |
| 229 | 227 | <th className="py-1 font-normal">Rework</th> | |
| 230 | 228 | <th className="py-1 font-normal">Reviews & comments</th> | |
| 231 | 229 | <th className="py-1 font-normal">Agent sessions</th> | |
| ⋯ | |||
| 242 | 240 | <td className="py-1.5 text-fg">{row.label}</td> | |
| 243 | 241 | <td>{cell(last.opened, now.opened)}</td> | |
| 244 | 242 | <td>{cell(last.merged, now.merged)}</td> | |
| 245 | − | <td>{cell(last.commits, now.commits)}</td> | |
| 246 | 243 | <td>{cell(last.reworked, now.reworked)}</td> | |
| 247 | 244 | <td>{cell(last.reviews, now.reviews)}</td> | |
| 248 | 245 | <td>{cell(last.sessions, now.sessions)}</td> | |
| 141 | 141 | export function SpendFlow({ flow, sized }: { flow: FlowNode[]; sized: "spend" | "pulls" }) { | |
| 142 | 142 | const [path, setPath] = useState<string[]>([]); | |
| 143 | 143 | const weight = weightOf(sized); | |
| 144 | − | // A workspace with no milestones starts at its work items: one "No milestone" box tells nothing. | |
| 145 | − | const skip = flow.length === 1 && flow[0]!.key === "m-"; | |
| 146 | − | const depth = skip ? 2 : 3; | |
| 147 | − | const heads = HEADS.slice(3 - depth); | |
| 148 | 144 | // The chosen node at each level: the one asked for, else the biggest. | |
| 149 | − | const columns: FlowNode[][] = [skip ? flow[0]!.children : flow]; | |
| 145 | + | const columns: FlowNode[][] = [flow]; | |
| 150 | 146 | const chosen: FlowNode[] = []; | |
| 151 | − | for (let level = 0; level < depth; level++) { | |
| 147 | + | for (let level = 0; level < 3; level++) { | |
| 152 | 148 | const nodes = columns[level] ?? []; | |
| 153 | 149 | const pick = nodes.find((node) => node.key === path[level]) ?? nodes[0]; | |
| 154 | 150 | if (!pick) break; | |
| 155 | 151 | chosen.push(pick); | |
| 156 | − | if (level < depth - 1) columns.push(pick.children); | |
| 152 | + | if (level < 2) columns.push(pick.children); | |
| 157 | 153 | } | |
| 158 | − | const pulls = chosen[depth - 1]?.list ?? []; | |
| 154 | + | const pulls = chosen[2]?.list ?? []; | |
| 159 | 155 | const choose = (level: number, key: string) => setPath([...chosen.slice(0, level).map((node) => node.key), key]); | |
| 160 | 156 | const boxes = columns.map((nodes) => stack(nodes.map(weight))); | |
| 161 | 157 | const rowHeight = pulls.length > 0 ? HEIGHT / Math.max(pulls.length, 5) : 0; | |
| ⋯ | |||
| 166 | 162 | return ( | |
| 167 | 163 | <div className="space-y-4"> | |
| 168 | 164 | <Legend /> | |
| 169 | − | {/* Wide: the columns, joined. */} | |
| 165 | + | {/* Wide: the four columns, joined. */} | |
| 170 | 166 | <div className="hidden md:block"> | |
| 171 | 167 | <div className="relative h-7"> | |
| 172 | − | {heads.map((head, column) => ( | |
| 168 | + | {HEADS.map((head, column) => ( | |
| 173 | 169 | <p key={head} className="absolute top-0 font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase" style={{ left: `${left(column)}%` }}> | |
| 174 | 170 | {head} | |
| 175 | 171 | </p> | |
| ⋯ | |||
| 180 | 176 | {chosen.map((node, level) => { | |
| 181 | 177 | const index = columns[level]!.indexOf(node); | |
| 182 | 178 | const from = boxes[level]![index]!; | |
| 183 | − | if (level < depth - 1) { | |
| 179 | + | if (level < 2) { | |
| 184 | 180 | const children = columns[level + 1]!; | |
| 185 | 181 | return slices(from, children.map(weight)).map((slice, i) => ( | |
| 186 | 182 | <path key={`${level}-${i}`} d={band(level, slice, boxes[level + 1]![i]!)} className="fill-line-strong" opacity={0.45} /> | |
| ⋯ | |||
| 213 | 209 | )} | |
| 214 | 210 | <div | |
| 215 | 211 | className="absolute rounded-xl border border-line bg-surface" | |
| 216 | − | style={{ left: `${left(depth)}%`, width: `${100 - left(depth)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }} | |
| 212 | + | style={{ left: `${left(3)}%`, width: `${100 - left(3)}%`, top: 0, height: pulls.length * rowHeight || MIN_BOX }} | |
| 217 | 213 | > | |
| 218 | 214 | {pulls.length === 0 ? ( | |
| 219 | 215 | <p className="px-3 py-4 text-sm text-muted">No pull requests here.</p> | |
| ⋯ | |||
| 227 | 223 | <div className="space-y-5 md:hidden"> | |
| 228 | 224 | {columns.map((nodes, level) => ( | |
| 229 | 225 | <div key={level} className="space-y-2"> | |
| 230 | − | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{heads[level]}</p> | |
| 226 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[level]}</p> | |
| 231 | 227 | <div className="grid gap-2"> | |
| 232 | 228 | {nodes.map((node) => ( | |
| 233 | 229 | <NodeBox key={node.key} node={node} sized={sized} chosen={chosen[level]?.key === node.key} onChoose={() => choose(level, node.key)} /> | |
| ⋯ | |||
| 236 | 232 | </div> | |
| 237 | 233 | ))} | |
| 238 | 234 | <div className="space-y-2"> | |
| 239 | − | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{heads[depth]}</p> | |
| 235 | + | <p className="font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase">{HEADS[3]}</p> | |
| 240 | 236 | <div className="rounded-xl border border-line bg-surface"> | |
| 241 | 237 | {pulls.map((pull) => ( | |
| 242 | 238 | <PullRow key={pull.key} pull={pull} style={{ height: 48 }} /> | |
| 49 | 49 | return ( | |
| 50 | 50 | <span className={cn("inline-flex min-w-0 items-center gap-1", className)}> | |
| 51 | 51 | {shown.map((repo) => ( | |
| 52 | − | <Hint key={repo.repoId} label={`${repo.path || "A deleted repository"}: ${ROLE_LABELS[repo.role].toLowerCase()}${repo.landed ? ", landed" : ""}`}> | |
| 52 | + | <Hint | |
| 53 | + | key={repo.repoId} | |
| 54 | + | label={`${repo.path || "A deleted repository"}${repo.deleted ? ", deleted" : ""}: ${ROLE_LABELS[repo.role].toLowerCase()}${repo.landed ? ", landed" : ""}`} | |
| 55 | + | > | |
| 53 | 56 | <span | |
| 54 | 57 | className={cn( | |
| 55 | 58 | "inline-flex max-w-32 min-w-0 items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[0.6875rem] leading-4", | |
| 56 | − | repo.landed ? "border-merged/40 text-merged" : repo.role === "target" ? "border-line-strong text-fg-soft" : "border-line text-muted", | |
| 59 | + | repo.deleted | |
| 60 | + | ? "border-dashed border-line text-faint line-through" | |
| 61 | + | : repo.landed | |
| 62 | + | ? "border-merged/40 text-merged" | |
| 63 | + | : repo.role === "target" | |
| 64 | + | ? "border-line-strong text-fg-soft" | |
| 65 | + | : "border-line text-muted", | |
| 57 | 66 | )} | |
| 58 | 67 | > | |
| 59 | 68 | {repo.landed && <Check size={10} className="shrink-0" />} |
| 3 | 3 | ||
| 4 | 4 | import type { Claim, ItemRepo, WorkItem, WorkspaceSession } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { boardOf, pullRepos, pullsByItem, shownStage, stageOf } from "./board.ts"; | |
| 6 | + | import { type ItemPull, boardOf, cappedNotes, pullRepos, pullsByItem, readPages, shownStage, stageOf } from "./board.ts"; | |
| 7 | 7 | ||
| 8 | 8 | const NOW = Date.parse("2026-10-17T12:00:00Z"); | |
| 9 | 9 | const DAY = 86_400_000; | |
| ⋯ | |||
| 64 | 64 | }; | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | /** Pull requests of these statuses, each its own, closing the item unless `part`. */ | |
| 68 | + | const pr = (...statuses: (ItemPull["status"] | `part:${ItemPull["status"]}`)[]): ItemPull[] => | |
| 69 | + | statuses.map((status, index) => { | |
| 70 | + | const part = status.startsWith("part:"); | |
| 71 | + | return { key: `acme/web#${index + 100}`, status: (part ? status.slice(5) : status) as ItemPull["status"], part }; | |
| 72 | + | }); | |
| 73 | + | ||
| 67 | 74 | const claim = (number: number, who: string): Claim => | |
| 68 | 75 | ({ id: `clm_${number}`, item: number, workspace: "acme", state: "active", actor: { id: "usr_9", username: who } }) as Claim; | |
| 69 | 76 | ||
| ⋯ | |||
| 72 | 79 | assert.equal(stageOf(item(2, { assignees: ["ana"] }), {}, NOW), "progress"); | |
| 73 | 80 | assert.equal(stageOf(item(3), { claimed: true }, NOW), "progress"); | |
| 74 | 81 | assert.equal(stageOf(item(4), { live: true }, NOW), "progress"); | |
| 75 | − | assert.equal(stageOf(item(5), { pulls: ["draft"] }, NOW), "progress"); | |
| 76 | − | assert.equal(stageOf(item(6, { assignees: ["ana"] }), { pulls: ["draft", "open"] }, NOW), "review"); | |
| 82 | + | assert.equal(stageOf(item(5), { pulls: pr("draft") }, NOW), "progress"); | |
| 83 | + | assert.equal(stageOf(item(6, { assignees: ["ana"] }), { pulls: pr("draft", "open") }, NOW), "review"); | |
| 77 | 84 | // Landed in one target, not yet the other: Landing, even with a pull request open. | |
| 78 | 85 | const landing = item(7, { repos: [repo("acme/web", "target", true), repo("acme/api", "target")] }); | |
| 79 | − | assert.equal(stageOf(landing, { pulls: ["open"] }, NOW), "landing"); | |
| 86 | + | assert.equal(stageOf(landing, { pulls: pr("open") }, NOW), "landing"); | |
| 80 | 87 | // A touched repository's landing is not a target's. | |
| 81 | 88 | assert.equal(stageOf(item(8, { repos: [repo("acme/web", "touched", true), repo("acme/api", "target")] }), {}, NOW), "backlog"); | |
| 82 | 89 | assert.equal(stageOf(item(9, { state: "closed", closedAt: new Date(NOW - 3 * DAY).toISOString() }), {}, NOW), "done"); | |
| ⋯ | |||
| 95 | 102 | ]; | |
| 96 | 103 | const board = boardOf( | |
| 97 | 104 | items, | |
| 98 | − | { claims: [claim(2, "cy")], sessions: [live(3, { pull: 9, pullStatus: "open" }), live(1, { state: "ended" })], pulls: pullsByItem([{ item: 4, status: "draft" }, { item: null, status: "open" }]) }, | |
| 105 | + | { claims: [claim(2, "cy")], sessions: [live(3, { pull: 9, pullStatus: "open" }), live(1, { state: "ended" })], pulls: pullsByItem([{ repo: "acme/web", number: 20, item: 4, status: "draft" }, { repo: "acme/web", number: 21, item: null, status: "open" }]) }, | |
| 99 | 106 | NOW, | |
| 100 | 107 | ); | |
| 101 | 108 | assert.deepEqual(board.backlog.map((card) => card.item.number), [1]); | |
| ⋯ | |||
| 124 | 131 | { state: "closed", repos: [{ path: "acme/old" }] }, | |
| 125 | 132 | // A repository deleted before work items kept no path. | |
| 126 | 133 | { state: "open", repos: [{ path: "" }] }, | |
| 134 | + | // One deleted and not yet purged keeps its path, and has nothing to read. | |
| 135 | + | { state: "open", repos: [{ path: "acme/hello", deleted: true }] }, | |
| 127 | 136 | ]; | |
| 128 | 137 | assert.deepEqual(pullRepos(items), ["acme/web", "acme/api", "acme/repo-21"]); | |
| 129 | 138 | // However many there are: none is left out past a limit. | |
| 130 | 139 | const many = Array.from({ length: 30 }, (_, index) => ({ state: "open", repos: [{ path: `acme/r${index}` }] })); | |
| 131 | 140 | assert.equal(pullRepos(many).length, 30); | |
| 132 | 141 | }); | |
| 142 | + | ||
| 143 | + | test("closed as not planned, a duplicate among them, is not Done: it did not ship", () => { | |
| 144 | + | const closed = { state: "closed" as const, closedAt: new Date(NOW - DAY).toISOString() }; | |
| 145 | + | assert.equal(stageOf(item(1, { ...closed, reason: "completed" }), {}, NOW), "done"); | |
| 146 | + | assert.equal(stageOf(item(2, { ...closed, reason: "not_planned" }), {}, NOW), null); | |
| 147 | + | const board = boardOf([item(1, { ...closed, reason: "completed" }), item(2, { ...closed, reason: "not_planned" })], {}, NOW); | |
| 148 | + | assert.deepEqual(board.done.map((card) => card.item.number), [1]); | |
| 149 | + | }); | |
| 150 | + | ||
| 151 | + | test("a merged pull request keeps an open item on the board: Landing with a target to land, else In progress", () => { | |
| 152 | + | const target = [repo("acme/web", "origin"), repo("acme/api", "target")]; | |
| 153 | + | // It closes the item and merged, but not in the target: still to land there. | |
| 154 | + | assert.equal(stageOf(item(1, { repos: target }), { pulls: pr("merged") }, NOW), "landing"); | |
| 155 | + | // Only part of it merged: more work to come. | |
| 156 | + | assert.equal(stageOf(item(2, { repos: target }), { pulls: pr("part:merged") }, NOW), "progress"); | |
| 157 | + | // No target left to land, still open (merged into another branch, or reopened). | |
| 158 | + | assert.equal(stageOf(item(3), { pulls: pr("merged") }, NOW), "progress"); | |
| 159 | + | // An open pull request beside a merged part: In review. | |
| 160 | + | assert.equal(stageOf(item(4), { pulls: pr("part:merged", "open") }, NOW), "review"); | |
| 161 | + | // Nothing merged or open: where its claims put it. | |
| 162 | + | assert.equal(stageOf(item(5, { repos: target }), { pulls: pr("closed") }, NOW), "backlog"); | |
| 163 | + | }); | |
| 164 | + | ||
| 165 | + | test("a pull request counts for its item, the items it closes and those it is part of, once each", () => { | |
| 166 | + | const by = pullsByItem([ | |
| 167 | + | { repo: "acme/web", number: 158, item: 7, status: "merged", closes: [7, 8], parts: [9, 8] }, | |
| 168 | + | { repo: "Acme/Web", number: 158, item: 7, status: "merged", closes: [7] }, | |
| 169 | + | { repo: "acme/api", number: 3, item: null, status: "open", parts: [9] }, | |
| 170 | + | ]); | |
| 171 | + | assert.deepEqual(by[7], [{ key: "acme/web#158", status: "merged", part: false }]); | |
| 172 | + | assert.deepEqual(by[8], [{ key: "acme/web#158", status: "merged", part: false }]); | |
| 173 | + | assert.deepEqual(by[9], [ | |
| 174 | + | { key: "acme/web#158", status: "merged", part: true }, | |
| 175 | + | { key: "acme/api#3", status: "open", part: true }, | |
| 176 | + | ]); | |
| 177 | + | }); | |
| 178 | + | ||
| 179 | + | test("a card says how many of its pull requests merged, counting a live session's once", () => { | |
| 180 | + | const items = [item(1), item(2, { state: "closed", closedAt: new Date(NOW).toISOString() })]; | |
| 181 | + | const pulls = pullsByItem([ | |
| 182 | + | { repo: "acme/web", number: 30, item: 1, status: "merged" }, | |
| 183 | + | { repo: "acme/web", number: 31, item: null, status: "merged", parts: [1] }, | |
| 184 | + | { repo: "acme/web", number: 32, item: 2, status: "merged" }, | |
| 185 | + | ]); | |
| 186 | + | const board = boardOf(items, { sessions: [live(1, { pull: 30, pullStatus: "merged" })], pulls }, NOW); | |
| 187 | + | assert.equal(board.progress[0]!.merged, 2); | |
| 188 | + | // A closed one's are not counted: it is Done. | |
| 189 | + | assert.equal(board.done[0]!.merged, 0); | |
| 190 | + | }); | |
| 191 | + | ||
| 192 | + | test("every page is read, each below the last one's lowest number, up to a ceiling the board then names", async () => { | |
| 193 | + | const numbers = Array.from({ length: 250 }, (_, index) => ({ number: 250 - index })); | |
| 194 | + | const asked: (number | undefined)[] = []; | |
| 195 | + | const read = async (before: number | undefined) => { | |
| 196 | + | asked.push(before); | |
| 197 | + | return numbers.filter((row) => before == null || row.number < before).slice(0, 100); | |
| 198 | + | }; | |
| 199 | + | const all = await readPages(read, 100, 10); | |
| 200 | + | assert.equal(all.rows.length, 250); | |
| 201 | + | assert.equal(all.capped, false); | |
| 202 | + | assert.deepEqual(asked, [undefined, 151, 51]); | |
| 203 | + | const some = await readPages(read, 100, 2); | |
| 204 | + | assert.equal(some.rows.length, 200); | |
| 205 | + | assert.equal(some.capped, true); | |
| 206 | + | // An exact page then nothing: one more read says it is the end. | |
| 207 | + | const exact = await readPages(async (before) => (before == null ? numbers.slice(0, 100) : []), 100, 10); | |
| 208 | + | assert.deepEqual([exact.rows.length, exact.capped], [100, false]); | |
| 209 | + | }); | |
| 210 | + | ||
| 211 | + | test("what the board read only in part, it says", () => { | |
| 212 | + | assert.deepEqual(cappedNotes({ items: null, done: null, pulls: [] }), []); | |
| 213 | + | assert.deepEqual(cappedNotes({ items: 1000, done: 100, pulls: ["acme/web"] }), [ | |
| 214 | + | "Showing the newest 1,000 open work items. Older ones are on the Work list.", | |
| 215 | + | "Done shows the 100 most recently closed.", | |
| 216 | + | "Only the newest pull requests in acme/web were read, so some work items may stand a column early.", | |
| 217 | + | ]); | |
| 218 | + | assert.match(cappedNotes({ items: null, done: null, pulls: ["acme/web", "acme/api"] })[0]!, /in 2 repositories/); | |
| 219 | + | }); | |
| 12 | 12 | ||
| 13 | 13 | export const STAGE_INFO: Record<Stage, { label: string; hint: string }> = { | |
| 14 | 14 | backlog: { label: "Backlog", hint: "Open, and nobody is on it" }, | |
| 15 | − | progress: { label: "In progress", hint: "Claimed or assigned, a live session on it, or a draft pull request" }, | |
| 15 | + | progress: { label: "In progress", hint: "Claimed or assigned, a live session on it, a draft pull request, or a pull request merged that did not close it" }, | |
| 16 | 16 | review: { label: "In review", hint: "A pull request for it is open" }, | |
| 17 | − | landing: { label: "Landing", hint: "Landed in some of its repositories, still missing in others" }, | |
| 18 | − | done: { label: "Done", hint: "Closed in the last 14 days" }, | |
| 17 | + | landing: { label: "Landing", hint: "Landed or merged somewhere, and a target repository is still missing it" }, | |
| 18 | + | done: { label: "Done", hint: "Closed as completed in the last 14 days" }, | |
| 19 | 19 | }; | |
| 20 | 20 | ||
| 21 | − | /** How long a closed work item stays in Done. */ | |
| 21 | + | /** How long a closed work item stays in Done, and how far back the board reads merged pull requests. */ | |
| 22 | 22 | export const DONE_DAYS = 14; | |
| 23 | 23 | ||
| 24 | − | /** What is known of a work item's pull requests: their statuses. */ | |
| 25 | − | export type ItemPulls = Record<number, PullStatus[]>; | |
| 24 | + | /** | |
| 25 | + | * One of a work item's pull requests, as the board counts it: one for the | |
| 26 | + | * item (`item`), one that closes it (`closes`), or one that is only part | |
| 27 | + | * of it (`parts`), which merging does not close. | |
| 28 | + | */ | |
| 29 | + | export type ItemPull = { | |
| 30 | + | /** `owner/name#number`, any case, so one counted twice is counted once. */ | |
| 31 | + | key: string; | |
| 32 | + | status: PullStatus; | |
| 33 | + | /** It is only part of the work item: merging it lands nothing. */ | |
| 34 | + | part: boolean; | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | /** What is known of each work item's pull requests, by the item's number. */ | |
| 38 | + | export type ItemPulls = Record<number, ItemPull[]>; | |
| 26 | 39 | ||
| 27 | 40 | /** Who or what is on a work item, for its card. */ | |
| 28 | 41 | export type OnIt = { | |
| ⋯ | |||
| 33 | 46 | }; | |
| 34 | 47 | ||
| 35 | 48 | /** | |
| 36 | − | * The stage a work item is in, the first that holds: closed recently is | |
| 37 | − | * Done; landed in some target repositories and not others is Landing; an | |
| 38 | − | * open pull request is In review; claimed, assigned, a live session or a | |
| 39 | − | * draft is In progress; anything else open is Backlog. Null for one closed | |
| 40 | − | * longer ago than [`DONE_DAYS`], which the board leaves out. | |
| 49 | + | * The stage a work item is in, the first that holds: | |
| 50 | + | * | |
| 51 | + | * - **Done**: closed as completed in the last [`DONE_DAYS`]. Closed as not | |
| 52 | + | * planned (which duplicates are) did not ship, and is left out, as is | |
| 53 | + | * one closed longer ago: null. | |
| 54 | + | * - **Landing**: landed in some target repositories and not others; or a | |
| 55 | + | * pull request that closes it merged and a target is still missing it | |
| 56 | + | * (it merged somewhere that is not a target). | |
| 57 | + | * - **In review**: a pull request for it is open. | |
| 58 | + | * - **In progress**: claimed, assigned, a live session, a draft, or a pull | |
| 59 | + | * request merged without closing it (part of it, or into a branch that | |
| 60 | + | * is not the default). | |
| 61 | + | * - **Backlog**: anything else open. | |
| 41 | 62 | */ | |
| 42 | − | export function stageOf(item: WorkItem, facts: { pulls?: PullStatus[]; claimed?: boolean; live?: boolean }, now: number): Stage | null { | |
| 63 | + | export function stageOf(item: WorkItem, facts: { pulls?: ItemPull[]; claimed?: boolean; live?: boolean }, now: number): Stage | null { | |
| 43 | 64 | if (item.state === "closed") { | |
| 65 | + | if (item.reason === "not_planned") return null; | |
| 44 | 66 | const closed = item.closedAt ? Date.parse(item.closedAt) : 0; | |
| 45 | 67 | return now - closed <= DONE_DAYS * 86_400_000 ? "done" : null; | |
| 46 | 68 | } | |
| 47 | 69 | const targets = item.repos.filter((repo) => repo.role === "target"); | |
| 48 | − | if (targets.some((repo) => repo.landed) && targets.some((repo) => !repo.landed)) return "landing"; | |
| 70 | + | const missing = targets.some((repo) => !repo.landed); | |
| 71 | + | if (missing && targets.some((repo) => repo.landed)) return "landing"; | |
| 49 | 72 | const pulls = facts.pulls ?? []; | |
| 50 | − | if (pulls.includes("open")) return "review"; | |
| 51 | − | if (facts.claimed || facts.live || item.assignees.length > 0 || pulls.includes("draft")) return "progress"; | |
| 73 | + | if (missing && pulls.some((pull) => pull.status === "merged" && !pull.part)) return "landing"; | |
| 74 | + | if (pulls.some((pull) => pull.status === "open")) return "review"; | |
| 75 | + | const merged = pulls.some((pull) => pull.status === "merged"); | |
| 76 | + | if (facts.claimed || facts.live || item.assignees.length > 0 || merged || pulls.some((pull) => pull.status === "draft")) return "progress"; | |
| 52 | 77 | return "backlog"; | |
| 53 | 78 | } | |
| 54 | 79 | ||
| 55 | − | export type BoardCard = { item: WorkItem; stage: Stage; on: OnIt }; | |
| 80 | + | export type BoardCard = { | |
| 81 | + | item: WorkItem; | |
| 82 | + | stage: Stage; | |
| 83 | + | on: OnIt; | |
| 84 | + | /** How many of an open work item's pull requests merged in the last [`DONE_DAYS`] (none for a closed one). */ | |
| 85 | + | merged: number; | |
| 86 | + | }; | |
| 56 | 87 | ||
| 57 | 88 | export type Board = Record<Stage, BoardCard[]>; | |
| 58 | 89 | ||
| ⋯ | |||
| 73 | 104 | seen.add(item.number); | |
| 74 | 105 | const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active"); | |
| 75 | 106 | const live = sessions.filter((session) => session.item === item.number && session.state !== "ended"); | |
| 76 | − | // A live session's pull request counts as one of the item's. | |
| 77 | − | const known = [...(pulls[item.number] ?? []), ...live.flatMap((session) => (session.pullStatus ? [session.pullStatus] : []))]; | |
| 78 | − | const stage = stageOf(item, { pulls: known, claimed: claimed.length > 0, live: live.length > 0 }, now); | |
| 107 | + | // A live session's pull request counts as one of the item's, once. | |
| 108 | + | const known = new Map((pulls[item.number] ?? []).map((pull) => [pull.key, pull])); | |
| 109 | + | for (const session of live) { | |
| 110 | + | if (session.pull == null || !session.pullStatus) continue; | |
| 111 | + | const key = `${session.repo}#${session.pull}`.toLowerCase(); | |
| 112 | + | if (!known.has(key)) known.set(key, { key, status: session.pullStatus, part: false }); | |
| 113 | + | } | |
| 114 | + | const stage = stageOf(item, { pulls: [...known.values()], claimed: claimed.length > 0, live: live.length > 0 }, now); | |
| 79 | 115 | if (!stage) continue; | |
| 80 | 116 | const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))]; | |
| 81 | − | board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live } }); | |
| 117 | + | const merged = item.state === "open" ? [...known.values()].filter((pull) => pull.status === "merged").length : 0; | |
| 118 | + | board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live }, merged }); | |
| 82 | 119 | } | |
| 83 | 120 | for (const stage of STAGES) { | |
| 84 | 121 | const at = (card: BoardCard) => Date.parse(stage === "done" ? (card.item.closedAt ?? card.item.updatedAt) : card.item.updatedAt); | |
| ⋯ | |||
| 88 | 125 | } | |
| 89 | 126 | ||
| 90 | 127 | /** | |
| 91 | − | * The repositories whose open pull requests the board reads: every one an | |
| 92 | − | * open work item is linked to, each once, so an item's pull request moves | |
| 93 | − | * it wherever it was opened. A repository deleted before work items, with | |
| 94 | − | * no path kept, has none to read. | |
| 128 | + | * The repositories whose pull requests the board reads: every one an open | |
| 129 | + | * work item is linked to, each once, so an item's pull request moves it | |
| 130 | + | * wherever it was opened. A deleted one (which can still be restored) has | |
| 131 | + | * none anyone can read, nor one purged with no path kept. | |
| 95 | 132 | */ | |
| 96 | − | export function pullRepos(items: readonly { state: string; repos: readonly { path: string }[] }[]): string[] { | |
| 133 | + | export function pullRepos(items: readonly { state: string; repos: readonly { path: string; deleted?: boolean }[] }[]): string[] { | |
| 97 | 134 | const seen = new Map<string, string>(); | |
| 98 | 135 | for (const item of items) { | |
| 99 | 136 | if (item.state !== "open") continue; | |
| 100 | − | for (const repo of item.repos) if (repo.path && !seen.has(repo.path.toLowerCase())) seen.set(repo.path.toLowerCase(), repo.path); | |
| 137 | + | for (const repo of item.repos) { | |
| 138 | + | if (!repo.path || repo.deleted || seen.has(repo.path.toLowerCase())) continue; | |
| 139 | + | seen.set(repo.path.toLowerCase(), repo.path); | |
| 140 | + | } | |
| 101 | 141 | } | |
| 102 | 142 | return [...seen.values()]; | |
| 103 | 143 | } | |
| 104 | 144 | ||
| 105 | − | /** Each work item's pull requests' statuses, from pull requests that name their item. */ | |
| 106 | − | export function pullsByItem(pulls: { item: number | null; status: PullStatus }[]): ItemPulls { | |
| 145 | + | /** A pull request as the board reads it, with the repository it is in. */ | |
| 146 | + | export type ReadPull = { repo: string; number: number; item: number | null; status: PullStatus; closes?: number[]; parts?: number[] }; | |
| 147 | + | ||
| 148 | + | /** | |
| 149 | + | * Each work item's pull requests: one counts for every work item it is | |
| 150 | + | * for (`item`), closes (`closes`) or is part of (`parts`), and only as a | |
| 151 | + | * part when it does not also close it. | |
| 152 | + | */ | |
| 153 | + | export function pullsByItem(pulls: readonly ReadPull[]): ItemPulls { | |
| 107 | 154 | const by: ItemPulls = {}; | |
| 155 | + | const add = (number: number, pull: ItemPull) => { | |
| 156 | + | const known = (by[number] ??= []); | |
| 157 | + | if (!known.some((one) => one.key === pull.key)) known.push(pull); | |
| 158 | + | }; | |
| 108 | 159 | for (const pull of pulls) { | |
| 109 | − | if (pull.item == null) continue; | |
| 110 | − | (by[pull.item] ??= []).push(pull.status); | |
| 160 | + | const key = `${pull.repo}#${pull.number}`.toLowerCase(); | |
| 161 | + | const closing = new Set([...(pull.item == null ? [] : [pull.item]), ...(pull.closes ?? [])]); | |
| 162 | + | for (const number of closing) add(number, { key, status: pull.status, part: false }); | |
| 163 | + | for (const number of pull.parts ?? []) if (!closing.has(number)) add(number, { key, status: pull.status, part: true }); | |
| 111 | 164 | } | |
| 112 | 165 | return by; | |
| 113 | 166 | } | |
| 114 | 167 | ||
| 168 | + | /** A list read page after page: its rows, and whether there may be more than were read. */ | |
| 169 | + | export type Paged<T> = { rows: T[]; capped: boolean }; | |
| 170 | + | ||
| 171 | + | /** | |
| 172 | + | * Every page of a list that comes newest first by number: `read` gives the | |
| 173 | + | * page below `before` (none for the first), and a page shorter than `size` | |
| 174 | + | * is the last. At most `pages` are read; `capped` says there may be more. | |
| 175 | + | */ | |
| 176 | + | export async function readPages<T extends { number: number }>( | |
| 177 | + | read: (before: number | undefined) => Promise<T[]>, | |
| 178 | + | size: number, | |
| 179 | + | pages: number, | |
| 180 | + | ): Promise<Paged<T>> { | |
| 181 | + | const rows: T[] = []; | |
| 182 | + | let before: number | undefined; | |
| 183 | + | for (let page = 0; page < pages; page++) { | |
| 184 | + | const found = await read(before); | |
| 185 | + | rows.push(...found); | |
| 186 | + | if (found.length < size) return { rows, capped: false }; | |
| 187 | + | before = Math.min(...found.map((row) => row.number)); | |
| 188 | + | } | |
| 189 | + | return { rows, capped: true }; | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /** Pages of open work items the board reads at most: 1,000 at 100 a page. */ | |
| 193 | + | export const ITEM_PAGES = 10; | |
| 194 | + | /** Pages of each repository's open, and of its recently merged, pull requests the board reads at most. */ | |
| 195 | + | export const PULL_PAGES = 3; | |
| 196 | + | ||
| 197 | + | /** | |
| 198 | + | * What the board read only in part, in sentences: the open work items | |
| 199 | + | * when there were more than it reads (`items`, how many it showed), Done | |
| 200 | + | * when it holds a full page (`done`), and the repositories with more pull | |
| 201 | + | * requests than it reads (`pulls`). | |
| 202 | + | */ | |
| 203 | + | export function cappedNotes({ items, done, pulls }: { items: number | null; done: number | null; pulls: string[] }): string[] { | |
| 204 | + | const notes: string[] = []; | |
| 205 | + | if (items != null) notes.push(`Showing the newest ${items.toLocaleString("en-US")} open work items. Older ones are on the Work list.`); | |
| 206 | + | if (done != null) notes.push(`Done shows the ${done} most recently closed.`); | |
| 207 | + | if (pulls.length > 0) { | |
| 208 | + | const where = pulls.length === 1 ? pulls[0] : `${pulls.length} repositories`; | |
| 209 | + | notes.push(`Only the newest pull requests in ${where} were read, so some work items may stand a column early.`); | |
| 210 | + | } | |
| 211 | + | return notes; | |
| 212 | + | } | |
| 213 | + | ||
| 115 | 214 | /** The stage a phone shows, from the address: a known one, else the first with anything in it, else Backlog. */ | |
| 116 | 215 | export function shownStage(asked: string | null, board: Board): Stage { | |
| 117 | 216 | if (asked && (STAGES as string[]).includes(asked)) return asked as Stage; | |
| 3 | 3 | ||
| 4 | 4 | import type { WorkSession, WorkspaceSession } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { narrowed, peopleIn, repoName, runsIn, sessionCost, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 6 | + | import { narrowed, onItemSessionTitle, peopleIn, repoName, runsIn, sessionCost, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 7 | 7 | ||
| 8 | 8 | function listed(id: string, more: Partial<WorkspaceSession> = {}): WorkspaceSession { | |
| 9 | 9 | return { | |
| ⋯ | |||
| 54 | 54 | assert.equal(runsIn(listed("h", { kind: "recorded", harness: null, agent: "cursor" })), "cursor, recorded"); | |
| 55 | 55 | }); | |
| 56 | 56 | ||
| 57 | + | test("on a work item's card, a session with no title says what it is on, never a bare pull request number", () => { | |
| 58 | + | assert.equal(onItemSessionTitle(listed("a", { session: own({ title: "Fix login" }) })), "Fix login"); | |
| 59 | + | assert.equal(onItemSessionTitle(listed("d", { kind: "recorded", pull: 7, pullTitle: "Retry hooks" })), "Retry hooks"); | |
| 60 | + | assert.equal(onItemSessionTitle(listed("e", { kind: "agent", pull: 158 })), "On pull request #158"); | |
| 61 | + | assert.equal(onItemSessionTitle(listed("f", { kind: "agent", pull: null })), "Working on it"); | |
| 62 | + | }); | |
| 63 | + | ||
| 57 | 64 | test("Sessions keeps its filters in the address and drops what it does not know", () => { | |
| 58 | 65 | const filters = sessionsFilters(new URLSearchParams("repo=web&person=@bo&harness=codex&kind=bogus&state=recent")); | |
| 59 | 66 | assert.deepEqual(filters, { state: "recent", repo: "web", person: "bo", harness: "codex", kind: null, item: null }); | |
| 26 | 26 | return session.title?.trim() || session.pullTitle?.trim() || (session.pull != null ? `Pull request #${session.pull}` : "Untitled session"); | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | /** | |
| 30 | + | * What a session is called on a work item's card: its own title, else | |
| 31 | + | * what it is doing there. Never a bare "Pull request #N", whose number | |
| 32 | + | * would read as the work item's. | |
| 33 | + | */ | |
| 34 | + | export function onItemSessionTitle(session: WorkspaceSession): string { | |
| 35 | + | if (session.session || session.title?.trim() || session.pullTitle?.trim()) return workspaceSessionTitle(session); | |
| 36 | + | return session.pull != null ? `On pull request #${session.pull}` : "Working on it"; | |
| 37 | + | } | |
| 38 | + | ||
| 29 | 39 | /** What runs it, in a few words: the harness, g1t's agent, or the agent that recorded it. */ | |
| 30 | 40 | export function runsIn(session: Pick<WorkspaceSession, "kind" | "harness" | "agent">): string { | |
| 31 | 41 | if (session.kind === "agent") return "g1t's agent"; |
| 277 | 277 | author({ ownerId: "u1", ownerName: "ana", repoId: "r1", recent: 9, merged: 40 }), | |
| 278 | 278 | author({ ownerId: "u2", ownerName: "bo", repoId: "r1", firstMergedAt: recent, recent: 1, merged: 1 }), | |
| 279 | 279 | author({ ownerId: "u2", ownerName: "bo", repoId: "r2", firstMergedAt: "2026-02-01T00:00:00Z", merged: 12, recent: 2 }), | |
| 280 | − | author({ ownerId: "u3", ownerName: "cy", repoId: "r2", firstMergedAt: "2026-03-01T00:00:00Z", merged: 5, recent: 1 }), | |
| 281 | 280 | ]; | |
| 282 | 281 | assert.deepEqual( | |
| 283 | 282 | newGround(authors, NOW).map((row) => [row.name, row.repoId, row.elsewhere]), | |
| 284 | 283 | [["bo", "r1", 12]], | |
| 285 | 284 | ); | |
| 286 | − | assert.deepEqual(silos(authors.slice(0, 3), NOW), [], "two people: every project rests on one of them, which says nothing"); | |
| 287 | 285 | const [silo] = silos(authors, NOW); | |
| 288 | 286 | assert.equal(silo!.name, "ana"); | |
| 289 | 287 | assert.equal(silo!.share, 0.9); | |
| ⋯ | |||
| 308 | 306 | found.map((s) => s.key), | |
| 309 | 307 | ["blocked", "stalled"], | |
| 310 | 308 | ); | |
| 311 | − | assert.equal(quietHeadline(week("x")), "A quiet week: nothing has landed yet, and nothing is stuck."); | |
| 309 | + | assert.equal(quietHeadline(week("x")), "A quiet week: nothing has merged yet, and nothing is stuck."); | |
| 312 | 310 | assert.equal( | |
| 313 | 311 | quietHeadline(week("x", { merged: 4, agentMerged: 2 })), | |
| 314 | − | "Four changes landed this week, 50% of them an agent's, and nothing needs a closer look.", | |
| 315 | − | ); | |
| 316 | − | // A workspace that pushes straight to main: agents' direct commits are what landed. | |
| 317 | − | assert.equal( | |
| 318 | − | quietHeadline(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })), | |
| 319 | − | "Ten changes landed this week, 90% of them an agent's, and nothing needs a closer look.", | |
| 312 | + | "Four changes merged this week, 50% of them an agent's, and nothing needs a closer look.", | |
| 320 | 313 | ); | |
| 321 | 314 | }); | |
| 322 | 315 | ||
| ⋯ | |||
| 436 | 429 | "Codex is catching on: bo and cy started using it this month, after ana.", | |
| 437 | 430 | ); | |
| 438 | 431 | assert.equal(namesWords(["a", "b", "c", "d"]), "a, b and 2 others"); | |
| 439 | − | }); | |
| 440 | − | ||
| 441 | − | test("agents' direct commits count as what they landed, and as what spend is spread over", () => { | |
| 442 | − | const weeks = [week("2026-10-05", { merged: 2, agentMerged: 1, agentCommits: 3 }), week("2026-10-12", { merged: 1, agentMerged: 1, agentCommits: 9 })]; | |
| 443 | − | const [changes, spend] = pulse({ slug: "acme", weeks, spend: [4_000_000, 20_000_000] }); | |
| 444 | − | assert.equal(changes!.label, "Agent-authored changes"); | |
| 445 | − | assert.equal(changes!.value, "10"); | |
| 446 | − | assert.deepEqual(changes!.delta, { text: "+6", tone: "good" }); | |
| 447 | − | assert.equal(changes!.points[1], "Week of Oct 12: 1 merged, 9 pushed directly, of 10 changes"); | |
| 448 | − | assert.equal(spend!.value, "$2.00", "$20 over ten agent changes"); | |
| 449 | 432 | }); | |
| 114 | 114 | reviewSeconds: 0, | |
| 115 | 115 | stalled: 0, | |
| 116 | 116 | sessionCostUsd: 0, | |
| 117 | − | agentCommits: 0, | |
| 118 | 117 | }); | |
| 119 | 118 | ||
| 120 | 119 | /** Every week in `starts`, quiet ones too, for the repositories in `only` (every one when not given). */ | |
| ⋯ | |||
| 132 | 131 | week.reviewSeconds += row.reviewSeconds; | |
| 133 | 132 | week.stalled += row.stalled; | |
| 134 | 133 | week.sessionCostUsd += row.sessionCostUsd ?? 0; | |
| 135 | − | week.agentCommits += row.agentCommits ?? 0; | |
| 136 | 134 | } | |
| 137 | 135 | return starts.map((start) => byStart.get(start)!); | |
| 138 | 136 | } | |
| ⋯ | |||
| 174 | 172 | return week.agentMerged > 0 ? week.agentReworked / week.agentMerged : null; | |
| 175 | 173 | } | |
| 176 | 174 | ||
| 177 | − | /** | |
| 178 | − | * What agents landed in a week: their merged pull requests, and the | |
| 179 | − | * commits their sessions pushed outside any pull request (to the default | |
| 180 | − | * branch, most of all). A workspace that pushes straight to main lands | |
| 181 | − | * most of its work the second way. | |
| 182 | − | */ | |
| 183 | − | export function agentChanges(week: WeekTotals): number { | |
| 184 | − | return week.agentMerged + (week.agentCommits ?? 0); | |
| 185 | − | } | |
| 186 | − | ||
| 187 | − | /** Everything that landed in a week: merged pull requests, and agents' direct commits. */ | |
| 188 | − | export function allChanges(week: WeekTotals): number { | |
| 189 | − | return week.merged + (week.agentCommits ?? 0); | |
| 190 | − | } | |
| 191 | − | ||
| 192 | − | /** The share of what landed that was an agent's, 0 to 1. */ | |
| 175 | + | /** The share of merges that were an agent's, 0 to 1. */ | |
| 193 | 176 | export function agentShare(week: WeekTotals): number | null { | |
| 194 | − | return allChanges(week) > 0 ? agentChanges(week) / allChanges(week) : null; | |
| 177 | + | return week.merged > 0 ? week.agentMerged / week.merged : null; | |
| 195 | 178 | } | |
| 196 | 179 | ||
| 197 | − | /** Usage per agent change (merge or direct commit), in micros. */ | |
| 180 | + | /** Usage per agent merge, in micros. */ | |
| 198 | 181 | export function spendPerMerge(micros: number | null, week: WeekTotals): number | null { | |
| 199 | − | return micros != null && agentChanges(week) > 0 ? micros / agentChanges(week) : null; | |
| 182 | + | return micros != null && week.agentMerged > 0 ? micros / week.agentMerged : null; | |
| 200 | 183 | } | |
| 201 | 184 | ||
| 202 | 185 | // --- Words ------------------------------------------------------------------------ | |
| ⋯ | |||
| 299 | 282 | ||
| 300 | 283 | /** | |
| 301 | 284 | * The past week in four figures, each against the week before and drawn | |
| 302 | − | * over the last ten: what agents landed (merges and direct commits), usage | |
| 303 | − | * per agent change, stalled changes, and how often people asked for | |
| 304 | − | * changes on an agent's merged work. | |
| 285 | + | * over the last ten: agent merges, usage per agent merge, stalled changes, | |
| 286 | + | * and how often people asked for changes on an agent's work. | |
| 305 | 287 | */ | |
| 306 | 288 | export function pulse({ slug, weeks, spend }: PulseInput): PulseTile[] { | |
| 307 | 289 | const at = weeks.length - 1; | |
| ⋯ | |||
| 315 | 297 | return [ | |
| 316 | 298 | { | |
| 317 | 299 | key: "merges", | |
| 318 | − | label: "Agent-authored changes", | |
| 319 | − | value: String(agentChanges(now)), | |
| 320 | − | delta: deltaOf(agentChanges(now), before ? agentChanges(before) : null, count, true), | |
| 321 | − | series: weeks.map(agentChanges), | |
| 322 | − | points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} merged, ${week.agentCommits ?? 0} pushed directly, of ${allChanges(week)} changes`), | |
| 323 | − | hint: "Agents' merged pull requests, and the commits their sessions pushed outside one", | |
| 300 | + | label: "Agent-authored merges", | |
| 301 | + | value: String(now.agentMerged), | |
| 302 | + | delta: deltaOf(now.agentMerged, before?.agentMerged ?? null, count, true), | |
| 303 | + | series: weeks.map((week) => week.agentMerged), | |
| 304 | + | points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} of ${week.merged} merges`), | |
| 305 | + | hint: "Merged pull requests an agent made: g1t's agent, or a harness session tied to it", | |
| 324 | 306 | to: `/${slug}/-/overview?tab=landed`, | |
| 325 | 307 | }, | |
| 326 | 308 | { | |
| 327 | 309 | key: "spend", | |
| 328 | − | label: "Spend per agent change", | |
| 310 | + | label: "Spend per agent merge", | |
| 329 | 311 | value: dollars(perMerge[at] ?? null), | |
| 330 | 312 | delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false), | |
| 331 | 313 | series: perMerge, | |
| 332 | − | points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent changes" : dollars(micros)}`), | |
| 333 | − | hint: "g1t's agent usage at price and harness sessions' cost at list price, over what agents landed", | |
| 314 | + | points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent merges" : dollars(micros)}`), | |
| 315 | + | hint: "g1t's agent usage at price and harness sessions' cost at list price, over the agent merges", | |
| 334 | 316 | to: `/${slug}/-/usage`, | |
| 335 | 317 | }, | |
| 336 | 318 | { | |
| ⋯ | |||
| 664 | 646 | /** | |
| 665 | 647 | * A project where one person has done most of the recent work: their | |
| 666 | 648 | * share of its merges in the last 90 days, and whoever else has started | |
| 667 | − | * merging there in the last 30 (knowledge spreading, or not yet). Only in | |
| 668 | − | * a workspace where three people or more merged lately. | |
| 649 | + | * merging there in the last 30 (knowledge spreading, or not yet). | |
| 669 | 650 | */ | |
| 670 | 651 | export type Silo = { | |
| 671 | 652 | repoId: string; | |
| ⋯ | |||
| 677 | 658 | joining: { ownerId: string; name: string }[]; | |
| 678 | 659 | }; | |
| 679 | 660 | ||
| 680 | − | export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75, minPeople = 3 } = {}): Silo[] { | |
| 681 | − | // Fewer than `minPeople` people merging anywhere lately: every project rests on someone, and saying so tells nothing. | |
| 682 | − | const people = new Set(authors.filter((row) => row.recent > 0).map((row) => row.ownerId)); | |
| 683 | − | if (people.size < minPeople) return []; | |
| 661 | + | export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75 } = {}): Silo[] { | |
| 684 | 662 | const byRepo = new Map<string, SignalAuthor[]>(); | |
| 685 | 663 | for (const row of authors) byRepo.set(row.repoId, [...(byRepo.get(row.repoId) ?? []), row]); | |
| 686 | 664 | const found: Silo[] = []; | |
| ⋯ | |||
| 837 | 815 | ||
| 838 | 816 | /** The headline when nothing stands out: still a sentence about the week. */ | |
| 839 | 817 | export function quietHeadline(week: WeekTotals | undefined): string { | |
| 840 | − | const landed = week ? allChanges(week) : 0; | |
| 841 | − | if (!week || landed === 0) return "A quiet week: nothing has landed yet, and nothing is stuck."; | |
| 818 | + | if (!week || week.merged === 0) return "A quiet week: nothing has merged yet, and nothing is stuck."; | |
| 842 | 819 | const share = agentShare(week); | |
| 843 | − | return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`; | |
| 820 | + | return `${countWord(week.merged)} ${week.merged === 1 ? "change" : "changes"} merged this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`; | |
| 844 | 821 | } | |
| 845 | 822 | ||
| 846 | 823 | /** What Home shows: the pulse, and the findings with the one that leads. */ | |
| ⋯ | |||
| 999 | 976 | ||
| 1000 | 977 | // --- The rhythm of the work ------------------------------------------------------- | |
| 1001 | 978 | ||
| 1002 | − | export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number; commits: number }; | |
| 979 | + | export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number }; | |
| 1003 | 980 | ||
| 1004 | 981 | export type RhythmRow = { | |
| 1005 | 982 | key: string; | |
| ⋯ | |||
| 1018 | 995 | export function rhythm(days: SignalDay[], list: string[], groups: Group[], max = 6): RhythmRow[] { | |
| 1019 | 996 | const rows = groups.map((group) => { | |
| 1020 | 997 | const ids = new Set(group.repoIds); | |
| 1021 | − | const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 }])); | |
| 998 | + | const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 }])); | |
| 1022 | 999 | for (const row of days) { | |
| 1023 | 1000 | const day = byDay.get(row.day); | |
| 1024 | 1001 | if (!day || !ids.has(row.repoId)) continue; | |
| ⋯ | |||
| 1027 | 1004 | day.reworked += row.reworked; | |
| 1028 | 1005 | day.reviews += row.reviews; | |
| 1029 | 1006 | day.sessions += row.sessions; | |
| 1030 | − | day.commits += row.agentCommits ?? 0; | |
| 1031 | 1007 | } | |
| 1032 | 1008 | const out = list.map((day) => byDay.get(day)!); | |
| 1033 | − | const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions + day.commits, 0); | |
| 1009 | + | const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions, 0); | |
| 1034 | 1010 | return { key: group.key, label: group.label, note: group.note, to: group.to, days: out, total }; | |
| 1035 | 1011 | }); | |
| 1036 | 1012 | return rows | |
| ⋯ | |||
| 1048 | 1024 | reworked: sum.reworked + day.reworked, | |
| 1049 | 1025 | reviews: sum.reviews + day.reviews, | |
| 1050 | 1026 | sessions: sum.sessions + day.sessions, | |
| 1051 | − | commits: sum.commits + day.commits, | |
| 1052 | 1027 | }), | |
| 1053 | − | { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 }, | |
| 1028 | + | { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 }, | |
| 1054 | 1029 | ); | |
| 1055 | 1030 | } | |
| 1056 | 1031 | ||
| ⋯ | |||
| 1059 | 1034 | const half = Math.floor(row.days.length / 2); | |
| 1060 | 1035 | const last = rhythmSum(row.days.slice(0, half)); | |
| 1061 | 1036 | const now = rhythmSum(row.days.slice(half)); | |
| 1062 | − | const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged + sum.commits; | |
| 1037 | + | const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged; | |
| 1063 | 1038 | if (prs(now) === 0 && prs(last) > 0) return "Quiet this week after a busy last week"; | |
| 1064 | 1039 | if (now.reviews === 0 && now.opened > 0) return `${now.opened} opened this week, no reviews yet`; | |
| 1065 | 1040 | if (prs(now) > 1.5 * prs(last) && prs(now) >= 4) return "Busier than last week"; | |
| 1066 | 1041 | if (now.reworked > Math.max(2, last.reworked * 2)) return "More rework than last week"; | |
| 1067 | − | return `${now.merged + now.commits} landed this week, ${last.merged + last.commits} last week`; | |
| 1042 | + | return `${now.merged} merged this week, ${last.merged} last week`; | |
| 1068 | 1043 | } | |
| 1069 | 1044 | ||
| 1070 | 1045 | export type WaitingRow = WaitingPull & { repo: RepoPath; to: string }; | |
| 1 | − | import { Bot, Columns3, Plus } from "lucide-react"; | |
| 1 | + | import { Bot, Columns3, GitMerge, Plus } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { Pull } from "@g1t/contracts"; | |
| 4 | + | import { LIST_PAGE } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/board"; | |
| 7 | 7 | import { HarnessMark, StateDot } from "../../../components/sessions"; | |
| ⋯ | |||
| 11 | 11 | import { Hint } from "../../../components/ui/hint"; | |
| 12 | 12 | import { RepoChips } from "../../../components/work-items"; | |
| 13 | 13 | import { ItemIcon } from "../../../components/work-icons"; | |
| 14 | − | import { type Board, type BoardCard, STAGES, STAGE_INFO, type Stage, boardOf, pullRepos, pullsByItem, shownStage } from "../../../lib/board"; | |
| 14 | + | import { | |
| 15 | + | type Board, | |
| 16 | + | type BoardCard, | |
| 17 | + | DONE_DAYS, | |
| 18 | + | ITEM_PAGES, | |
| 19 | + | PULL_PAGES, | |
| 20 | + | type ReadPull, | |
| 21 | + | STAGES, | |
| 22 | + | STAGE_INFO, | |
| 23 | + | type Stage, | |
| 24 | + | boardOf, | |
| 25 | + | cappedNotes, | |
| 26 | + | pullRepos, | |
| 27 | + | pullsByItem, | |
| 28 | + | readPages, | |
| 29 | + | shownStage, | |
| 30 | + | } from "../../../lib/board"; | |
| 15 | 31 | import { cn } from "../../../lib/cn"; | |
| 16 | 32 | import { page } from "../../../lib/meta"; | |
| 17 | 33 | import { isFailed, part } from "../../../lib/partial"; | |
| ⋯ | |||
| 19 | 35 | import { workPages } from "../../../lib/repo-live"; | |
| 20 | 36 | import { work } from "../../../lib/services.server"; | |
| 21 | 37 | import { getViewer, roleIn, unwrap } from "../../../lib/session.server"; | |
| 22 | − | import { runsIn, sessionHref, workspaceSessionTitle } from "../../../lib/sessions"; | |
| 38 | + | import { onItemSessionTitle, runsIn, sessionHref } from "../../../lib/sessions"; | |
| 23 | 39 | import { useWorkspaceLive } from "../../../lib/use-repo-live"; | |
| 24 | 40 | import { itemPath, newItemPath, repoFilter, shortPath, workPath } from "../../../lib/work-items"; | |
| 25 | 41 | ||
| ⋯ | |||
| 35 | 51 | const repoName = query.get("repo")?.trim() || null; | |
| 36 | 52 | const repo = repoName ? repoFilter(workspace, repoName) : null; | |
| 37 | 53 | const member = roleIn(viewer, workspace) != null; | |
| 38 | − | // The work items are what the board is; what says where each stands, a part each. | |
| 54 | + | const now = Date.now(); | |
| 55 | + | const since = new Date(now - DONE_DAYS * 86_400_000).toISOString(); | |
| 56 | + | // The open work items are what the board is, every page of them; what says where each stands, a part each. | |
| 39 | 57 | const [open, closed, claims, live] = await Promise.all([ | |
| 40 | − | work.listWorkItems(workspace, viewer, { state: "open", repo: repo ?? undefined }), | |
| 41 | − | part(work.listWorkItems(workspace, viewer, { state: "closed", repo: repo ?? undefined }), "Done"), | |
| 58 | + | readPages((before) => work.listWorkItems(workspace, viewer, { state: "open", repo: repo ?? undefined, before }).then(unwrap), LIST_PAGE, ITEM_PAGES), | |
| 59 | + | part(work.listWorkItems(workspace, viewer, { state: "closed", repo: repo ?? undefined, closedSince: since }), "Done"), | |
| 42 | 60 | part(work.listClaims(workspace, viewer, { state: "active", repo: repo ?? undefined }), "Claims"), | |
| 43 | 61 | part(work.listWorkspaceSessions(workspace, viewer, { shown: "live", sessionsOnly: true, repo: repoName ?? undefined, limit: 200 }), "Live sessions"), | |
| 44 | 62 | ]); | |
| 45 | 63 | const sessions = isFailed(live) || !live.ok ? [] : live.value.sessions; | |
| 46 | − | // The open pull requests that name their work items, in every repository an open item is linked to. | |
| 47 | − | const openItems = unwrap(open); | |
| 48 | − | const repos = pullRepos(openItems); | |
| 49 | − | const pulls = await Promise.all( | |
| 50 | − | repos.map((path) => { | |
| 51 | − | const [namespace = "", name = ""] = path.split("/"); | |
| 52 | − | return work.listPulls({ namespace, name }, viewer, "open").catch(() => null); | |
| 53 | − | }), | |
| 54 | − | ); | |
| 55 | − | const openPulls: Pull[] = pulls.flatMap((found) => (found?.ok ? found.value : [])); | |
| 56 | − | // A repository whose pull requests could not be read: its items may stand a column early, and the board says so. | |
| 57 | − | const unread = repos.filter((_, index) => !pulls[index]?.ok); | |
| 58 | − | const now = Date.now(); | |
| 59 | − | const board = boardOf( | |
| 60 | − | [...openItems, ...(isFailed(closed) || !closed.ok ? [] : closed.value)], | |
| 61 | − | { claims: isFailed(claims) || !claims.ok ? [] : claims.value, sessions, pulls: pullsByItem(openPulls) }, | |
| 62 | − | now, | |
| 63 | − | ); | |
| 64 | + | // The open and recently merged pull requests in every repository an open item is linked to. | |
| 65 | + | const repos = pullRepos(open.rows); | |
| 66 | + | const read = (path: string, state: "open" | "closed") => { | |
| 67 | + | const [namespace = "", name = ""] = path.split("/"); | |
| 68 | + | // Not found or refused is an answer: none the viewer can see there. | |
| 69 | + | const page = (before: number | undefined) => | |
| 70 | + | work | |
| 71 | + | .listPulls({ namespace, name }, viewer, state, { before, mergedSince: state === "closed" ? since : undefined }) | |
| 72 | + | .then((found) => (found.ok ? found.value : [])); | |
| 73 | + | return part(readPages(page, LIST_PAGE, PULL_PAGES), `Pull requests in ${path}`); | |
| 74 | + | }; | |
| 75 | + | const pulls = await Promise.all(repos.map((path) => Promise.all([read(path, "open"), read(path, "closed")]))); | |
| 76 | + | const readPulls: ReadPull[] = []; | |
| 77 | + | const unread: string[] = []; | |
| 78 | + | const capped: string[] = []; | |
| 79 | + | repos.forEach((path, index) => { | |
| 80 | + | const both = pulls[index]!; | |
| 81 | + | if (both.some(isFailed)) unread.push(path); | |
| 82 | + | for (const found of both) { | |
| 83 | + | if (isFailed(found)) continue; | |
| 84 | + | if (found.capped) capped.push(path); | |
| 85 | + | readPulls.push(...found.rows.map((pull) => ({ ...pull, repo: path }))); | |
| 86 | + | } | |
| 87 | + | }); | |
| 88 | + | const done = isFailed(closed) || !closed.ok ? [] : closed.value; | |
| 89 | + | const board = boardOf([...open.rows, ...done], { claims: isFailed(claims) || !claims.ok ? [] : claims.value, sessions, pulls: pullsByItem(readPulls) }, now); | |
| 64 | 90 | return { | |
| 65 | 91 | workspace, | |
| 66 | 92 | member, | |
| ⋯ | |||
| 68 | 94 | repo: repoName, | |
| 69 | 95 | board, | |
| 70 | 96 | stage: shownStage(query.get("stage"), board), | |
| 97 | + | // A part that could not be read: its items may be missing, or stand a column early, and the board says so. | |
| 71 | 98 | unavailable: [ | |
| 72 | 99 | ...[closed, claims, live].filter(isFailed).map((failed) => failed.what), | |
| 73 | 100 | ...(unread.length === 0 ? [] : [unread.length === 1 ? `Pull requests in ${unread[0]}` : `Pull requests in ${unread.length} repositories`]), | |
| 74 | 101 | ], | |
| 102 | + | // What was read only in part, said plainly rather than left out silently. | |
| 103 | + | capped: cappedNotes({ items: open.capped ? open.rows.length : null, done: done.length >= LIST_PAGE ? LIST_PAGE : null, pulls: [...new Set(capped)] }), | |
| 75 | 104 | live: sessions.length, | |
| 76 | 105 | }; | |
| 77 | 106 | } | |
| ⋯ | |||
| 93 | 122 | </span> | |
| 94 | 123 | </Hint> | |
| 95 | 124 | )} | |
| 125 | + | {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>} | |
| 96 | 126 | {sessions.slice(0, 2).map((session) => { | |
| 97 | 127 | const to = sessionHref(workspace, session); | |
| 98 | 128 | const label = ( | |
| ⋯ | |||
| 103 | 133 | </span> | |
| 104 | 134 | ); | |
| 105 | 135 | return ( | |
| 106 | − | <Hint key={session.id} label={`${workspaceSessionTitle(session)} · ${runsIn(session)}`}> | |
| 136 | + | <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}> | |
| 107 | 137 | {to ? ( | |
| 108 | 138 | <Link to={to} className="min-w-0 hover:text-fg"> | |
| 109 | 139 | {label} | |
| ⋯ | |||
| 136 | 166 | {item.title} <span className="font-normal text-faint">#{item.number}</span> | |
| 137 | 167 | </span> | |
| 138 | 168 | </Link> | |
| 139 | − | <RepoChips repos={item.repos} workspace={workspace} /> | |
| 169 | + | {(item.repos.length > 0 || card.merged > 0) && ( | |
| 170 | + | <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> | |
| 171 | + | <RepoChips repos={item.repos} workspace={workspace} /> | |
| 172 | + | {card.merged > 0 && ( | |
| 173 | + | <Hint label={`${card.merged === 1 ? "A pull request" : `${card.merged} pull requests`} for it merged in the last ${DONE_DAYS} days`}> | |
| 174 | + | <span className="inline-flex shrink-0 items-center gap-1 text-[0.6875rem] text-merged"> | |
| 175 | + | <GitMerge size={11} /> | |
| 176 | + | {card.merged} merged | |
| 177 | + | </span> | |
| 178 | + | </Hint> | |
| 179 | + | )} | |
| 180 | + | </div> | |
| 181 | + | )} | |
| 140 | 182 | <OnIt card={card} workspace={workspace} /> | |
| 141 | 183 | </Card> | |
| 142 | 184 | </li> | |
| ⋯ | |||
| 172 | 214 | * stages that wraps rather than scrolling sideways. | |
| 173 | 215 | */ | |
| 174 | 216 | export default function BoardPage({ loaderData }: Route.ComponentProps) { | |
| 175 | − | const { workspace, board, stage, repo, member, signedIn, unavailable, live } = loaderData; | |
| 217 | + | const { workspace, board, stage, repo, member, signedIn, unavailable, capped, live } = loaderData; | |
| 176 | 218 | const total = STAGES.reduce((sum, one) => sum + board[one].length, 0); | |
| 177 | 219 | const query = (more: Record<string, string>) => `?${new URLSearchParams({ ...(repo ? { repo } : {}), ...more }).toString()}`; | |
| 178 | 220 | useWorkspaceLive({ workspace, member, match: workPages.list() }); | |
| ⋯ | |||
| 197 | 239 | )} | |
| 198 | 240 | </div> | |
| 199 | 241 | {unavailable.length > 0 && <p className="mt-3 text-xs text-warn">{unavailable.join(", ")} could not be loaded just now, so this board is partial: some work items may be missing, or stand a column early.</p>} | |
| 242 | + | {capped.length > 0 && <p className="mt-2 text-xs text-muted">{capped.join(" ")}</p>} | |
| 200 | 243 | ||
| 201 | 244 | {total === 0 ? ( | |
| 202 | 245 | <div className="mt-5"> | |
| 342 | 342 | return ( | |
| 343 | 343 | <li className="px-3 py-3 sm:px-4"> | |
| 344 | 344 | <div className="flex min-w-0 items-center gap-2"> | |
| 345 | − | {repo.path ? ( | |
| 345 | + | {repo.path && repo.deleted ? ( | |
| 346 | + | // Deleted, and restorable until it is purged: its name, nothing to open. | |
| 347 | + | <Hint label={`${repo.path} was deleted. It can be restored until it is purged.`}> | |
| 348 | + | <span className="min-w-0 truncate font-mono text-sm text-muted"> | |
| 349 | + | {repo.path} <span className="font-sans text-xs text-faint">deleted</span> | |
| 350 | + | </span> | |
| 351 | + | </Hint> | |
| 352 | + | ) : repo.path ? ( | |
| 346 | 353 | <Link to={`/${repo.path}`} className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent"> | |
| 347 | 354 | {repo.path} | |
| 348 | 355 | </Link> | |
| ⋯ | |||
| 535 | 542 | const pullHref = pullHrefs(repoRows.map(({ repo, pulls: repoPulls }) => ({ path: repo.path, pulls: repoPulls }))); | |
| 536 | 543 | const failureBase = `/${originOf(item.repos)?.path || workspace}`; | |
| 537 | 544 | // Where a pull request or a session would be opened: a target still missing, else where it was filed. | |
| 545 | + | // A deleted repository takes no pull request. | |
| 546 | + | const openable = item.repos.filter((repo) => repo.path && !repo.deleted); | |
| 538 | 547 | const workIn = missing[0] ?? item.repos.find((repo) => repo.role === "target")?.path ?? originOf(item.repos)?.path ?? null; | |
| 539 | 548 | const linkError = actionData && "action" in actionData && actionData.action === "link" ? actionData.error : null; | |
| 540 | 549 | const sideError = actionData && "action" in actionData && ["blocked-by", "start-session", "react", "link", "open-pull"].includes(String(actionData.action)); | |
| ⋯ | |||
| 922 | 931 | <div className="mt-3"> | |
| 923 | 932 | <CopyLine text={reference} /> | |
| 924 | 933 | </div> | |
| 925 | − | {viewer && item.repos.length > 0 ? ( | |
| 934 | + | {viewer && openable.length > 0 ? ( | |
| 926 | 935 | <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4"> | |
| 927 | 936 | <input type="hidden" name="action" value="open-pull" /> | |
| 928 | 937 | <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p> | |
| 929 | 938 | <SelectField | |
| 930 | 939 | name="repo" | |
| 931 | 940 | aria-label="Repository" | |
| 932 | − | defaultValue={workIn ?? item.repos[0]!.path} | |
| 933 | − | options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))} | |
| 941 | + | defaultValue={openable.some((repo) => repo.path === workIn) ? workIn! : openable[0]!.path} | |
| 942 | + | options={openable.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))} | |
| 934 | 943 | /> | |
| 935 | 944 | <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} /> | |
| 936 | 945 | <div className="*:w-full"> | |
| 1679 | 1679 | pub repo_id: Option<String>, | |
| 1680 | 1680 | } | |
| 1681 | 1681 | ||
| 1682 | − | /// `list_pulls`, newest first. Returns `Outcome<Vec<Pull>>`. | |
| 1682 | + | /// `list_pulls`, newest first, at most 100 (`before` pages on). Returns | |
| 1683 | + | /// `Outcome<Vec<Pull>>`. | |
| 1683 | 1684 | #[derive(Debug, Serialize, Deserialize)] | |
| 1684 | 1685 | pub struct ListPullsArgs { | |
| 1685 | 1686 | pub repo: RepoPath, | |
| ⋯ | |||
| 1695 | 1696 | /// Only pull requests into this branch. | |
| 1696 | 1697 | #[serde(default)] | |
| 1697 | 1698 | pub base: Option<String>, | |
| 1699 | + | /// Only pull requests numbered below this: the next page, after one | |
| 1700 | + | /// whose lowest number this is. | |
| 1701 | + | #[serde(default)] | |
| 1702 | + | pub before: Option<u32>, | |
| 1703 | + | /// Only pull requests merged at or after this time (RFC 3339). | |
| 1704 | + | #[serde(default)] | |
| 1705 | + | pub merged_since: Option<String>, | |
| 1698 | 1706 | } | |
| 1699 | 1707 | ||
| 1700 | 1708 | /// `pulls_for_repos`: the newest open and the newest closed pull requests | |
| 61 | 61 | /// A pull request or commit that closes it landed in this repository. | |
| 62 | 62 | #[serde(default)] | |
| 63 | 63 | pub landed: bool, | |
| 64 | + | /// The repository is deleted, and can still be restored until it is | |
| 65 | + | /// purged: `path` is where it was. It has nothing to open or read. | |
| 66 | + | #[serde(default)] | |
| 67 | + | pub deleted: bool, | |
| 64 | 68 | } | |
| 65 | 69 | ||
| 66 | 70 | /// What resolved a work item in one repository: a pull request merged, or | |
| ⋯ | |||
| 247 | 251 | pub milestone: Option<u32>, | |
| 248 | 252 | } | |
| 249 | 253 | ||
| 250 | − | /// `list_work_items`, newest first: a workspace's work items the viewer | |
| 251 | − | /// may see. With `repo` (then `workspace` may be empty, meaning the | |
| 254 | + | /// `list_work_items`, newest first, at most 100 (`before` pages on): a | |
| 255 | + | /// workspace's work items the viewer may see. With `repo` (then `workspace` may be empty, meaning the | |
| 252 | 256 | /// repository's), only those linked to it, in any role or in `role`. | |
| 253 | 257 | /// Returns `Outcome<Vec<WorkItem>>`. | |
| 254 | 258 | #[derive(Debug, Serialize, Deserialize)] | |
| ⋯ | |||
| 272 | 276 | /// Only those assigned to this username. | |
| 273 | 277 | #[serde(default)] | |
| 274 | 278 | pub assignee: Option<String>, | |
| 279 | + | /// Only those closed at or after this time (RFC 3339), the most | |
| 280 | + | /// recently closed first instead of the newest. | |
| 281 | + | #[serde(default)] | |
| 282 | + | pub closed_since: Option<String>, | |
| 283 | + | /// Only those numbered below this: the next page, after one whose | |
| 284 | + | /// lowest number this is. | |
| 285 | + | #[serde(default)] | |
| 286 | + | pub before: Option<u32>, | |
| 275 | 287 | } | |
| 276 | 288 | ||
| 277 | 289 | /// `get_work_item` (`Outcome<WorkItemDetail>`), `get_work` | |
| ⋯ | |||
| 450 | 462 | use super::*; | |
| 451 | 463 | ||
| 452 | 464 | fn link(path: &str, role: LinkRole, landed: bool) -> ItemRepo { | |
| 453 | − | ItemRepo { repo_id: format!("rep_{path}"), path: path.into(), role, legacy_number: None, landed } | |
| 465 | + | ItemRepo { repo_id: format!("rep_{path}"), path: path.into(), role, legacy_number: None, landed, deleted: false } | |
| 454 | 466 | } | |
| 455 | 467 | ||
| 456 | 468 | #[test] | |
| 58 | 58 | /// What the harness sessions started in the week cost, as they | |
| 59 | 59 | /// reported it (estimated at list price from their token usage). | |
| 60 | 60 | pub session_cost_usd: f64, | |
| 61 | − | /// Commits agent sessions pushed outside any pull request in the week | |
| 62 | − | /// (to the default branch, most of all), each once. | |
| 63 | − | pub agent_commits: u32, | |
| 64 | 61 | } | |
| 65 | 62 | ||
| 66 | 63 | /// One repository's UTC day. Days with nothing in them are left out. | |
| ⋯ | |||
| 79 | 76 | pub reviews: u32, | |
| 80 | 77 | /// Harness sessions started. | |
| 81 | 78 | pub sessions: u32, | |
| 82 | − | /// Commits agent sessions pushed outside any pull request. | |
| 83 | − | pub agent_commits: u32, | |
| 84 | 79 | } | |
| 85 | 80 | ||
| 86 | 81 | /// An open pull request no one has reviewed yet, oldest first. | |
| 493 | 493 | label: filter.label ?? null, | |
| 494 | 494 | milestone: filter.milestone ?? null, | |
| 495 | 495 | assignee: filter.assignee ?? null, | |
| 496 | + | closed_since: filter.closedSince ?? null, | |
| 497 | + | before: filter.before ?? null, | |
| 496 | 498 | }), | |
| 497 | 499 | getWorkItem: (workspace, number, viewer) => call("get_work_item", { workspace, number, viewer }), | |
| 498 | 500 | updateWorkItem: (actor, workspace, number, input) => | |
| ⋯ | |||
| 570 | 572 | updateSettings: (actor, repo, settings) => | |
| 571 | 573 | call("update_settings", { actor, repo, settings }), | |
| 572 | 574 | openPull: (actor, repo, input) => call("open_pull", { actor, repo, ...input }), | |
| 573 | − | listPulls: (repo, viewer, state, filter = {}) => call("list_pulls", { repo, viewer, state, ...filter }), | |
| 575 | + | listPulls: (repo, viewer, state, { mergedSince, ...filter } = {}) => | |
| 576 | + | call("list_pulls", { repo, viewer, state, ...filter, merged_since: mergedSince ?? null }), | |
| 574 | 577 | deliveryInsights: (repoIds, viewer, from, until) => call("delivery_insights", { repoIds, viewer, from, until }), | |
| 575 | 578 | workspaceSignals: (args, viewer) => call("workspace_signals", { ...args, viewer }), | |
| 576 | 579 | pullsForRepos: (repoIds, viewer, limit) => call("pulls_for_repos", { repoIds, viewer, limit }), | |
| 34 | 34 | legacyNumber: number | null; | |
| 35 | 35 | /** A pull request or commit that closes it landed in this repository. */ | |
| 36 | 36 | landed: boolean; | |
| 37 | + | /** | |
| 38 | + | * The repository is deleted, and can still be restored until it is | |
| 39 | + | * purged: `path` is where it was. It has nothing to open or read. | |
| 40 | + | */ | |
| 41 | + | deleted?: boolean; | |
| 37 | 42 | }; | |
| 38 | 43 | ||
| 39 | 44 | /** What resolved a work item in one repository: a pull request merged, or a commit pushed. */ | |
| ⋯ | |||
| 871 | 876 | blocked_by?: number[]; | |
| 872 | 877 | }; | |
| 873 | 878 | ||
| 879 | + | /** How many `listWorkItems` and `listPulls` give at most in one call. */ | |
| 880 | + | export const LIST_PAGE = 100; | |
| 881 | + | ||
| 874 | 882 | /** The filters on `listWorkItems`. */ | |
| 875 | 883 | export type WorkItemFilter = { | |
| 876 | 884 | /** Only those linked to this repository (in any role, or in `role`). */ | |
| ⋯ | |||
| 881 | 889 | milestone?: number; | |
| 882 | 890 | /** Only those assigned to this username. */ | |
| 883 | 891 | assignee?: string; | |
| 892 | + | /** Only those closed at or after this time (RFC 3339), the most recently closed first instead of the newest. */ | |
| 893 | + | closedSince?: string; | |
| 894 | + | /** Only those numbered below this: the next page, after one whose lowest number this is. */ | |
| 895 | + | before?: number; | |
| 884 | 896 | }; | |
| 885 | 897 | ||
| 886 | 898 | export type OpenPullInput = { | |
| ⋯ | |||
| 942 | 954 | * meant to land. | |
| 943 | 955 | */ | |
| 944 | 956 | openWorkItem(actor: User, input: OpenWorkItemInput): Promise<Result<WorkItem>>; | |
| 945 | − | /** A workspace's work items the viewer may see, newest first. */ | |
| 957 | + | /** A workspace's work items the viewer may see, newest first, at most [`LIST_PAGE`] (`before` pages on). */ | |
| 946 | 958 | listWorkItems(workspace: string, viewer: Viewer, filter?: WorkItemFilter): Promise<Result<WorkItem[]>>; | |
| 947 | 959 | getWorkItem(workspace: string, number: number, viewer: Viewer): Promise<Result<WorkItemDetail>>; | |
| 948 | 960 | /** Its owner, or someone with the Triage role on it, may. */ | |
| ⋯ | |||
| 1089 | 1101 | * branch, one ready for review. | |
| 1090 | 1102 | */ | |
| 1091 | 1103 | openPull(actor: User, repo: RepoPath, input: OpenPullInput): Promise<Result<Pull>>; | |
| 1092 | − | /** Newest first. */ | |
| 1104 | + | /** Newest first, at most [`LIST_PAGE`] (`before` pages on). */ | |
| 1093 | 1105 | listPulls( | |
| 1094 | 1106 | repo: RepoPath, | |
| 1095 | 1107 | viewer: Viewer, | |
| 1096 | 1108 | state?: State, | |
| 1097 | − | filter?: { label?: string; milestone?: number; base?: string }, | |
| 1109 | + | filter?: { | |
| 1110 | + | label?: string; | |
| 1111 | + | milestone?: number; | |
| 1112 | + | base?: string; | |
| 1113 | + | /** Only those numbered below this: the next page, after one whose lowest number this is. */ | |
| 1114 | + | before?: number; | |
| 1115 | + | /** Only those merged at or after this time (RFC 3339). */ | |
| 1116 | + | mergedSince?: string; | |
| 1117 | + | }, | |
| 1098 | 1118 | ): Promise<Result<Pull[]>>; | |
| 1099 | 1119 | /** | |
| 1100 | 1120 | * Delivery for up to 100 repositories over UTC days `from` through | |
| ⋯ | |||
| 1908 | 1928 | reviewSeconds: number; | |
| 1909 | 1929 | stalled: number; | |
| 1910 | 1930 | sessionCostUsd: number; | |
| 1911 | − | /** Commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. */ | |
| 1912 | − | agentCommits: number; | |
| 1913 | 1931 | }; | |
| 1914 | − | export type SignalDay = { repoId: string; day: string; opened: number; merged: number; agentMerged: number; reworked: number; reviews: number; sessions: number; agentCommits: number }; | |
| 1932 | + | export type SignalDay = { repoId: string; day: string; opened: number; merged: number; agentMerged: number; reworked: number; reviews: number; sessions: number }; | |
| 1915 | 1933 | /** An open pull request no one has reviewed, oldest first; `blocks`: the open work items waiting on its work item. */ | |
| 1916 | 1934 | export type WaitingPull = { repoId: string; number: number; title: string; createdAt: string; agent: boolean; draftSeconds: number | null; item: number | null; blocks: number[] }; | |
| 1917 | 1935 | export type SignalPull = { | |
| 1149 | 1149 | label: None, | |
| 1150 | 1150 | milestone: None, | |
| 1151 | 1151 | base: None, | |
| 1152 | + | before: None, | |
| 1153 | + | merged_since: None, | |
| 1152 | 1154 | }, | |
| 1153 | 1155 | ) | |
| 1154 | 1156 | .await |
| 767 | 767 | label: None, | |
| 768 | 768 | milestone: None, | |
| 769 | 769 | assignee: None, | |
| 770 | + | closed_since: None, | |
| 771 | + | before: None, | |
| 770 | 772 | }, | |
| 771 | 773 | ) | |
| 772 | 774 | .await?; | |
| ⋯ | |||
| 788 | 790 | let pulls: Outcome<Vec<Pull>> = g1t_kit::call( | |
| 789 | 791 | &self.work, | |
| 790 | 792 | "list_pulls", | |
| 791 | − | &ListPullsArgs { repo: path.clone(), viewer: Some(actor), state: None, label: None, milestone: None, base: None }, | |
| 793 | + | &ListPullsArgs { repo: path.clone(), viewer: Some(actor), state: None, label: None, milestone: None, base: None, before: None, merged_since: None }, | |
| 792 | 794 | ) | |
| 793 | 795 | .await?; | |
| 794 | 796 | let Outcome::Ok(pulls) = pulls else { return Ok(()) }; | |
| 23 | 23 | sha2 = "0.10" | |
| 24 | 24 | ||
| 25 | 25 | [dev-dependencies] | |
| 26 | − | # limits: the tests' databases refuse what D1 refuses (signals/tests.rs). | |
| 27 | − | rusqlite = { version = "0.32", features = ["bundled", "limits"] } | |
| 26 | + | rusqlite = { version = "0.32", features = ["bundled"] } | |
| 28 | 27 | ||
| 29 | 28 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 30 | 29 | # time. |
| 1 | + | -- When a repository was deleted, while it can still be restored: its work | |
| 2 | + | -- items' links say so, and the board reads nothing from it. Set on | |
| 3 | + | -- repo.deleted, cleared on repo.restored, and the row goes on repo.purged. | |
| 4 | + | ALTER TABLE repo_workspaces ADD COLUMN deleted_at TEXT; | |
| 5 | + | ||
| 6 | + | -- The job that marks the repositories deleted before this column was | |
| 7 | + | -- there (items/migration.rs `mark_deleted_repos`). It runs once the jobs | |
| 8 | + | -- before it are done, and again changes nothing. | |
| 9 | + | INSERT INTO work_jobs (name) VALUES ('deleted_marks'); |
| 445 | 445 | fn a_repository_already_linked_is_not_suggested() { | |
| 446 | 446 | let candidates = vec![("rep_web".to_owned(), "acme/web".to_owned())]; | |
| 447 | 447 | for role in LinkRole::ALL { | |
| 448 | − | let linked = ItemRepo { repo_id: "rep_web".into(), path: "acme/web".into(), role, legacy_number: None, landed: false }; | |
| 448 | + | let linked = ItemRepo { repo_id: "rep_web".into(), path: "acme/web".into(), role, legacy_number: None, landed: false, deleted: false }; | |
| 449 | 449 | let item = item("Fix web", "", &[], vec![linked]); | |
| 450 | 450 | assert!(suggest_targets(&item, &candidates, &[], &[]).is_empty(), "{role:?}"); | |
| 451 | 451 | } |
| 15 | 15 | use crate::{UNVERIFIED, Work}; | |
| 16 | 16 | ||
| 17 | 17 | /// Records where repository `?1` is: workspace `?2`, path `?3`, and | |
| 18 | − | /// whether it is private (`?4`). | |
| 18 | + | /// whether it is private (`?4`). It is there, so not deleted. | |
| 19 | 19 | pub(crate) const REMEMBER_REPO: &str = "INSERT INTO repo_workspaces (repo_id, workspace, path, private) | |
| 20 | 20 | VALUES (?1, ?2, ?3, ?4) | |
| 21 | − | ON CONFLICT (repo_id) DO UPDATE SET workspace = excluded.workspace, path = excluded.path, private = excluded.private"; | |
| 21 | + | ON CONFLICT (repo_id) DO UPDATE SET workspace = excluded.workspace, path = excluded.path, private = excluded.private, | |
| 22 | + | deleted_at = NULL"; | |
| 23 | + | ||
| 24 | + | /// `repo.deleted` (`?1` its id, at `?2`): its links say it is deleted | |
| 25 | + | /// until `repo.restored` records it again ([`REMEMBER_REPO`]) or | |
| 26 | + | /// `repo.purged` drops its row. | |
| 27 | + | pub(crate) const MARK_DELETED: &str = "UPDATE repo_workspaces SET deleted_at = COALESCE(deleted_at, ?2) WHERE repo_id = ?1"; | |
| 22 | 28 | ||
| 23 | 29 | /// Linking a work item (`?1`) that a change in repository `?2` is part of, | |
| 24 | 30 | /// at `?3`, by `?4`, unless it is linked already. |
| 28 | 28 | use g1t_kit::now_ms; | |
| 29 | 29 | use serde::Deserialize; | |
| 30 | 30 | use worker::Result; | |
| 31 | + | use worker::wasm_bindgen::JsValue; | |
| 31 | 32 | ||
| 32 | 33 | use crate::Work; | |
| 33 | 34 | use crate::optional; | |
| ⋯ | |||
| 173 | 174 | /// labels and old numbers unmerged. | |
| 174 | 175 | pub(crate) const DELETED_REPOS_JOB: &str = "deleted_repos"; | |
| 175 | 176 | ||
| 177 | + | /// The job after that ([`Work::mark_deleted_repos`]): marks each | |
| 178 | + | /// repository deleted but not purged as deleted in `repo_workspaces` | |
| 179 | + | /// ([`super::links::MARK_DELETED`]), for those deleted before that was | |
| 180 | + | /// kept. Run again, it changes nothing. | |
| 181 | + | pub(crate) const DELETED_MARKS_JOB: &str = "deleted_marks"; | |
| 182 | + | ||
| 176 | 183 | /// Every label a workspace's work items or pull requests carry that the | |
| 177 | 184 | /// workspace does not have, as a label (gray, no description) made at | |
| 178 | 185 | /// `?1`: one a repository had that was never merged. Run again, it adds | |
| ⋯ | |||
| 298 | 305 | return Ok(WorkMigration { done: true, ..WorkMigration::default() }); | |
| 299 | 306 | }; | |
| 300 | 307 | if job.done_at.is_some() { | |
| 301 | − | return self.migrate_deleted_repos().await; | |
| 308 | + | let deleted = self.migrate_deleted_repos().await?; | |
| 309 | + | if !deleted.done { | |
| 310 | + | return Ok(deleted); | |
| 311 | + | } | |
| 312 | + | return self.mark_deleted_repos().await; | |
| 302 | 313 | } | |
| 303 | 314 | let mut cursor = job.cursor; | |
| 304 | 315 | let mut recorded = 0; | |
| ⋯ | |||
| 347 | 358 | .map(|repo| (repo.id.clone(), repo.namespace.clone(), repo.name.clone(), repo.is_private)) | |
| 348 | 359 | .collect(); | |
| 349 | 360 | recorded += self.record_repos(&repos).await?; | |
| 361 | + | // Recording one says it is there; these are deleted. | |
| 362 | + | self.mark_deleted(&page).await?; | |
| 350 | 363 | match page.next { | |
| 351 | 364 | Some(next) => { | |
| 352 | 365 | self.db | |
| ⋯ | |||
| 370 | 383 | Ok(WorkMigration { done: false, repos: recorded, ..WorkMigration::default() }) | |
| 371 | 384 | } | |
| 372 | 385 | ||
| 386 | + | /// The job after [`Self::migrate_deleted_repos`] ([`DELETED_MARKS_JOB`]): | |
| 387 | + | /// marks each repository deleted but not purged as deleted, a few | |
| 388 | + | /// pages a run. Done once; run again, it changes nothing. | |
| 389 | + | async fn mark_deleted_repos(&self) -> Result<WorkMigration> { | |
| 390 | + | let job = self | |
| 391 | + | .db | |
| 392 | + | .prepare("SELECT cursor, done_at FROM work_jobs WHERE name = ?") | |
| 393 | + | .bind(&[DELETED_MARKS_JOB.into()])? | |
| 394 | + | .first::<JobRow>(None) | |
| 395 | + | .await?; | |
| 396 | + | let Some(job) = job.filter(|job| job.done_at.is_none()) else { | |
| 397 | + | return Ok(WorkMigration { done: true, ..WorkMigration::default() }); | |
| 398 | + | }; | |
| 399 | + | let mut cursor = job.cursor; | |
| 400 | + | let mut marked = 0; | |
| 401 | + | for _ in 0..PAGES_PER_RUN { | |
| 402 | + | let page: DeletedRepoPage = | |
| 403 | + | g1t_kit::call(&self.repos, "deleted_repos", &AllIdsArgs { after: cursor.clone(), limit: PAGE }).await?; | |
| 404 | + | marked += self.mark_deleted(&page).await?; | |
| 405 | + | let done = page.next.is_none(); | |
| 406 | + | self.db | |
| 407 | + | .prepare("UPDATE work_jobs SET cursor = ?, done_at = ? WHERE name = ? AND done_at IS NULL") | |
| 408 | + | .bind(&[ | |
| 409 | + | optional(&page.next), | |
| 410 | + | if done { rfc3339(now_ms()).into() } else { JsValue::NULL }, | |
| 411 | + | DELETED_MARKS_JOB.into(), | |
| 412 | + | ])? | |
| 413 | + | .run() | |
| 414 | + | .await?; | |
| 415 | + | if done { | |
| 416 | + | return Ok(WorkMigration { done: true, repos: marked, ..WorkMigration::default() }); | |
| 417 | + | } | |
| 418 | + | cursor = page.next; | |
| 419 | + | } | |
| 420 | + | Ok(WorkMigration { done: false, repos: marked, ..WorkMigration::default() }) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | /// Marks a page of deleted repositories as deleted where they are recorded. | |
| 424 | + | async fn mark_deleted(&self, page: &DeletedRepoPage) -> Result<u32> { | |
| 425 | + | if page.repos.is_empty() { | |
| 426 | + | return Ok(0); | |
| 427 | + | } | |
| 428 | + | let now = rfc3339(now_ms()); | |
| 429 | + | let statements = page | |
| 430 | + | .repos | |
| 431 | + | .iter() | |
| 432 | + | .map(|repo| self.db.prepare(super::links::MARK_DELETED).bind(&[repo.id.as_str().into(), now.as_str().into()])) | |
| 433 | + | .collect::<Result<Vec<_>>>()?; | |
| 434 | + | self.db.batch(statements).await?; | |
| 435 | + | Ok(page.repos.len() as u32) | |
| 436 | + | } | |
| 437 | + | ||
| 373 | 438 | /// Records repositories (id, workspace, name, private) where they are. | |
| 374 | 439 | async fn record_repos(&self, repos: &[(String, String, String, bool)]) -> Result<u32> { | |
| 375 | 440 | if repos.is_empty() { | |
| 62 | 62 | mod tests; | |
| 63 | 63 | ||
| 64 | 64 | pub(crate) use labels::Labeled; | |
| 65 | + | pub(crate) use links::MARK_DELETED; | |
| 65 | 66 | pub(crate) use landing::{Landing, PastLanding}; | |
| 66 | 67 | pub(crate) use migration::{DELETED, RENAMED, TRANSFERRED}; | |
| 67 | 68 | pub(crate) use reading::{held_rows, select}; |
| 16 | 16 | /// What `list_work_items` reads: `?1` the workspace, then the filters, and | |
| 17 | 17 | /// `?8` whether the viewer is a member (else only work items filed in a | |
| 18 | 18 | /// public repository or one in `?9`, the repositories they were given a | |
| 19 | − | /// role on). | |
| 19 | + | /// role on), at most `?10`. With `?11`, only those closed since then, the | |
| 20 | + | /// most recently closed first; with `?12`, only those numbered below it. | |
| 20 | 21 | const LIST_SQL: &str = "SELECT {ITEM_COLUMNS} FROM work_items | |
| 21 | 22 | WHERE workspace = ?1 AND (?2 IS NULL OR state = ?2) | |
| 22 | 23 | AND (?3 IS NULL OR EXISTS (SELECT 1 FROM json_each(work_items.labels) WHERE json_each.value = ?3)) | |
| ⋯ | |||
| 27 | 28 | AND (?8 = 1 OR EXISTS (SELECT 1 FROM work_item_repos o JOIN repo_workspaces rw ON rw.repo_id = o.repo_id | |
| 28 | 29 | WHERE o.item_id = work_items.id AND o.role = 'origin' | |
| 29 | 30 | AND (rw.private = 0 OR o.repo_id IN (SELECT value FROM json_each(?9))))) | |
| 30 | − | ORDER BY number DESC LIMIT ?10"; | |
| 31 | + | AND (?11 IS NULL OR closed_at >= ?11) | |
| 32 | + | AND (?12 IS NULL OR number < ?12) | |
| 33 | + | ORDER BY CASE WHEN ?11 IS NULL THEN NULL ELSE closed_at END DESC, number DESC LIMIT ?10"; | |
| 31 | 34 | ||
| 32 | 35 | /// The work items matching `condition` (on `work_items`), with everything | |
| 33 | 36 | /// they are read with. | |
| ⋯ | |||
| 216 | 219 | u32::from(member).into(), | |
| 217 | 220 | serde_json::to_string(&granted)?.into(), | |
| 218 | 221 | LIST_PAGE.into(), | |
| 222 | + | optional(&a.closed_since.map(|at| at.trim().to_owned()).filter(|at| !at.is_empty())), | |
| 223 | + | optional_number(a.before), | |
| 219 | 224 | ])?; | |
| 220 | 225 | let rows = self.timing.db(1, query.all()).await?.results::<ItemRow>()?; | |
| 221 | 226 | Ok(Outcome::Ok( | |
| ⋯ | |||
| 307 | 312 | ||
| 308 | 313 | #[cfg(test)] | |
| 309 | 314 | mod tests { | |
| 310 | − | use super::redact_paths; | |
| 315 | + | use rusqlite::{Connection, ToSql}; | |
| 316 | + | ||
| 317 | + | use super::super::links::{MARK_DELETED, REMEMBER_REPO}; | |
| 318 | + | use super::super::rows::ITEM_COLUMNS; | |
| 319 | + | use super::{LIST_SQL, redact_paths}; | |
| 320 | + | use crate::migrated::database; | |
| 321 | + | ||
| 322 | + | /// acme's work items 1 to 5, linked to acme/web; 3, 4 and 5 closed, | |
| 323 | + | /// in the order 4, 5, 3; 2 also to acme/hello. | |
| 324 | + | fn acme() -> Connection { | |
| 325 | + | let db = database(); | |
| 326 | + | db.execute_batch( | |
| 327 | + | "INSERT INTO repo_workspaces (repo_id, workspace, path, private) | |
| 328 | + | VALUES ('rep_web', 'acme', 'acme/web', 0), ('rep_hello', 'acme', 'acme/hello', 0); | |
| 329 | + | INSERT INTO work_items (id, workspace, number, title, body, labels, author_id, author_name, created_at, updated_at, | |
| 330 | + | state, closed_at) | |
| 331 | + | VALUES ('wki_1', 'acme', 1, 'One', '', '[]', 'usr_1', 'ana', '', '', 'open', NULL), | |
| 332 | + | ('wki_2', 'acme', 2, 'Two', '', '[]', 'usr_1', 'ana', '', '', 'open', NULL), | |
| 333 | + | ('wki_3', 'acme', 3, 'Three', '', '[]', 'usr_1', 'ana', '', '', 'closed', '2026-10-17T00:00:00Z'), | |
| 334 | + | ('wki_4', 'acme', 4, 'Four', '', '[]', 'usr_1', 'ana', '', '', 'closed', '2026-10-01T00:00:00Z'), | |
| 335 | + | ('wki_5', 'acme', 5, 'Five', '', '[]', 'usr_1', 'ana', '', '', 'closed', '2026-10-10T00:00:00Z'); | |
| 336 | + | INSERT INTO work_item_repos (item_id, repo_id, role, path, added_at) | |
| 337 | + | VALUES ('wki_1', 'rep_web', 'origin', 'acme/web', ''), ('wki_2', 'rep_hello', 'origin', 'acme/hello', ''), | |
| 338 | + | ('wki_2', 'rep_web', 'target', 'acme/web', ''), ('wki_3', 'rep_web', 'origin', 'acme/web', ''), | |
| 339 | + | ('wki_4', 'rep_web', 'origin', 'acme/web', ''), ('wki_5', 'rep_web', 'origin', 'acme/web', '');", | |
| 340 | + | ) | |
| 341 | + | .unwrap(); | |
| 342 | + | db | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// The numbers `list_work_items` reads for acme as a member, in its order. | |
| 346 | + | fn listed(db: &Connection, state: Option<&str>, limit: u32, closed_since: Option<&str>, before: Option<u32>) -> Vec<u32> { | |
| 347 | + | let sql = LIST_SQL.replace("{ITEM_COLUMNS}", ITEM_COLUMNS); | |
| 348 | + | let values: [&dyn ToSql; 12] = [&"acme", &state, &None::<String>, &None::<u32>, &None::<String>, &None::<String>, &None::<String>, &1, &"[]", &limit, &closed_since, &before]; | |
| 349 | + | let mut statement = db.prepare(&sql).unwrap(); | |
| 350 | + | let numbers = statement.query_map(&values[..], |row| row.get::<_, u32>("number")).unwrap(); | |
| 351 | + | numbers.map(Result::unwrap).collect() | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | #[test] | |
| 355 | + | fn work_items_page_on_by_number_and_closed_ones_come_by_when_they_closed() { | |
| 356 | + | let db = acme(); | |
| 357 | + | assert_eq!(listed(&db, None, 100, None, None), [5, 4, 3, 2, 1]); | |
| 358 | + | // A page, then the next from below its lowest number. | |
| 359 | + | assert_eq!(listed(&db, None, 2, None, None), [5, 4]); | |
| 360 | + | assert_eq!(listed(&db, None, 2, None, Some(4)), [3, 2]); | |
| 361 | + | assert_eq!(listed(&db, None, 2, None, Some(2)), [1]); | |
| 362 | + | // Closed since a time: the most recently closed first, older ones left out. | |
| 363 | + | assert_eq!(listed(&db, Some("closed"), 100, Some("2026-10-04T00:00:00Z"), None), [3, 5]); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | /// Whether each of work item 2's links says its repository is deleted, by path. | |
| 367 | + | fn deleted_links(db: &Connection) -> Vec<(String, bool)> { | |
| 368 | + | let links: String = db | |
| 369 | + | .query_row(&format!("SELECT links FROM (SELECT {ITEM_COLUMNS} FROM work_items WHERE id = 'wki_2')"), [], |row| row.get(0)) | |
| 370 | + | .unwrap(); | |
| 371 | + | let mut links: Vec<(String, bool)> = serde_json::from_str::<Vec<serde_json::Value>>(&links) | |
| 372 | + | .unwrap() | |
| 373 | + | .into_iter() | |
| 374 | + | .map(|link| (link["path"].as_str().unwrap().to_owned(), link["deleted"].as_i64() == Some(1))) | |
| 375 | + | .collect(); | |
| 376 | + | links.sort(); | |
| 377 | + | links | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | #[test] | |
| 381 | + | fn a_deleted_repository_is_marked_on_its_links_until_it_is_restored() { | |
| 382 | + | let db = acme(); | |
| 383 | + | assert_eq!(deleted_links(&db), [("acme/hello".to_owned(), false), ("acme/web".to_owned(), false)]); | |
| 384 | + | // repo.deleted: its path is kept, and its links say it is deleted. | |
| 385 | + | db.execute(MARK_DELETED, rusqlite::params!["rep_hello", "2026-10-15T00:00:00Z"]).unwrap(); | |
| 386 | + | assert_eq!(deleted_links(&db), [("acme/hello".to_owned(), true), ("acme/web".to_owned(), false)]); | |
| 387 | + | // Marked again (the job for those deleted before), it keeps when. | |
| 388 | + | db.execute(MARK_DELETED, rusqlite::params!["rep_hello", "2026-10-18T00:00:00Z"]).unwrap(); | |
| 389 | + | let at: String = db.query_row("SELECT deleted_at FROM repo_workspaces WHERE repo_id = 'rep_hello'", [], |row| row.get(0)).unwrap(); | |
| 390 | + | assert_eq!(at, "2026-10-15T00:00:00Z"); | |
| 391 | + | // repo.restored records it where it is: no longer deleted. | |
| 392 | + | db.execute(REMEMBER_REPO, rusqlite::params!["rep_hello", "acme", "acme/hello", 0]).unwrap(); | |
| 393 | + | assert_eq!(deleted_links(&db), [("acme/hello".to_owned(), false), ("acme/web".to_owned(), false)]); | |
| 394 | + | // repo.purged drops its row: the link keeps its path, and is not marked. | |
| 395 | + | db.execute(MARK_DELETED, rusqlite::params!["rep_hello", "2026-10-18T00:00:00Z"]).unwrap(); | |
| 396 | + | db.execute("DELETE FROM repo_workspaces WHERE repo_id = ?1", ["rep_hello"]).unwrap(); | |
| 397 | + | assert_eq!(deleted_links(&db), [("acme/hello".to_owned(), false), ("acme/web".to_owned(), false)]); | |
| 398 | + | } | |
| 311 | 399 | ||
| 312 | 400 | #[test] | |
| 313 | 401 | fn notes_do_not_name_repositories_the_reader_may_not_read() { | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…