Skip to content

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

The Work board reads deleted repositories, merged pull requests and its caps right #164

Mergedsyntaqx merged board-fixes into main
39 files+686−2197 files not counted0/39 viewed
+4−0
53165316 label: optional_text(input, "label"),
53175317 milestone: integer(input, "milestone"),
53185318 assignee: optional_text(input, "assignee"),
5319+ before: None,
5320+ closed_since: None,
53195321 },
53205322 )
53215323 .await
56625664 label: optional_text(input, "label"),
56635665 milestone: integer(input, "milestone"),
56645666 base: optional_text(input, "base"),
5667+ before: None,
5668+ merged_since: None,
56655669 },
56665670 )
56675671 .await
+2−0
161161 label: None,
162162 milestone: None,
163163 base: None,
164+ before: None,
165+ merged_since: None,
164166 },
165167 )
166168 .await?;
+4−4
4848
4949 | Figure | What it counts |
5050 | --- | --- |
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. |
5353 | **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. |
5555
5656 ### Whose work is an agent's
5757
7878 | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. |
7979 | **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). |
8080 | **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. |
8282
8383 Findings are ordered by how much they hold up: blocked work first, then
8484 waits, then what shipped and where spend went. Up to five show, and the
+4−7
682682 figures as Home.
683683 2. **Spend, threaded to the work**: the quarter's work in four columns:
684684 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
687686 what agents cost on its pull requests (g1t's metered usage, and each
688687 pull request's share of its sessions' cost estimated at list price),
689688 and shaded by how many were an agent's (**Agent use**, less to more). Choose a box to
695694 organization ran, so it is never added to g1t's.
696695 3. **What's moving and what's waiting**: each team's last two weeks, day
697696 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
701699 requests for changes. Turn each layer on or off above the rows, point
702700 at a day for its numbers, or open **The numbers, as a table**. Each
703701 row is drawn to its own scale. Beside it, **Waiting on a first
705703 4. **Quiet changes**: places to look, not verdicts.
706704 - **Knowledge silos**: projects where one person merged three quarters
707705 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.
710707 - **New ground**: people who merged to a project for the first time in
711708 the last two weeks, after three or more merges elsewhere.
712709 - **Spreading**: a harness two or more people in a team started using
+49−13
1313 | Stage | Holds the work items |
1414 | --- | --- |
1515 | **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. |
1717 | **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. |
2020
2121 A work item is in the first stage that holds for it, in the order
2222 **Done**, **Landing**, **In review**, **In progress**, **Backlog**. So a
2323 work item with one pull request in review and another still a draft is
2424 **In review**, and one that has landed in one of its two targets is
2525 **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
2731
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+
2847 ## How a work item moves
2948
3049 A typical work item goes left to right as the work happens:
3756 (`g1t pr create --item 12` opens a draft). A pull request a live session
3857 is on counts too.
3958 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/).
4566
4667 It moves back as well: a claim released or expired with nobody else on it
4768 and no pull request sends it back to **Backlog**, and reopening a work item
5273
5374 Each column lists its work items most recently changed first, and **Done**
5475 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.
5887
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+
5993 The Board follows the work as it happens, without reloading, and checks
6094 again every 30 seconds while a session is live, since a session going idle
6195 sends no event.
6599 - **On a phone** the Board shows one stage at a time, with a button for
66100 each and how many it holds; the address keeps it as `?stage=landing`.
67101 - **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.
69105
70106 **New work item** is on the Board too.
+2−0
5757 assert.equal(stageOf(item(1, { state: "closed", closed_at: new Date(NOW - 3 * DAY).toISOString() }), none), "done");
5858 assert.equal(stageOf(item(1, { state: "closed", closed_at: new Date(NOW - 15 * DAY).toISOString() }), none), null);
5959 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);
6062 // Done wins over everything else.
6163 const repos = [target("acme/web", true), target("acme/api", false)];
6264 assert.equal(stageOf(item(1, { state: "closed", repos, closed_at: new Date(NOW - DAY).toISOString() }), { ...none, pulls: [{ item: 1, status: "open" }] }), "done");
+9−6
33
44 /**
55 * 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
88 * review has an open pull request; In progress has someone on it (an
99 * assignee, a claim, a live session) or a draft; Backlog has nobody.
1010 * The first that holds, in that order, is its stage.
3232 /** How long a closed item stays in Done. */
3333 export const DONE_DAYS = 14;
3434
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+ };
3940
4041 /** A pull request for a work item, by the item's number in the workspace. */
4142 export type BoardPull = { item: number | null; status: PullStatus };
5758 export function stageOf(item: BoardItem, context: BoardContext): Stage | null {
5859 const now = context.now ?? Date.now();
5960 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;
6063 const closed = item.closed_at ? Date.parse(item.closed_at) : NaN;
6164 return !Number.isNaN(closed) && now - closed <= DONE_DAYS * 86_400_000 ? "done" : null;
6265 }
+7−10
11 /**
22 * What is moving and what is waiting (lib/signals.ts rhythm): each team's
33 * 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
65 * comments (a wash) and requests for changes (bars). Each row is drawn to
76 * its own scale, so a small team's week is as legible as a big one's; the
87 * numbers are in the readout and the table. Layers turn on and off above
1716 type Layer = "prs" | "rework" | "agent" | "reviews";
1817
1918 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" },
2120 { key: "rework", label: "Rework", swatch: "bg-faint" },
2221 { key: "agent", label: "Agent activity", swatch: "bg-series-1" },
2322 { key: "reviews", label: "Reviews & comments", swatch: "bg-series-4" },
5554 <ul className="mt-2 space-y-1 text-fg">
5655 <li className="flex items-center gap-2">
5756 <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
5958 </li>
6059 <li className="flex items-center gap-2">
6160 <span className="size-2 rounded-full bg-faint" />
9089 const n = row.days.length;
9190 const slot = WIDTH / n;
9291 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)));
9493 const down = Math.max(1, ...row.days.map((day) => Math.max(on.has("rework") ? day.reworked : 0, on.has("reviews") ? day.reviews : 0)));
9594 const above = (value: number) => MID - (value / up) * (MID - 6);
9695 const below = (value: number) => MID + (value / down) * (MID - 6);
134133 <line x1={0} x2={WIDTH} y1={MID} y2={MID} className="stroke-line-strong" strokeWidth={1} vectorEffect="non-scaling-stroke" />
135134 {on.has("prs") &&
136135 row.days.map((d, i) =>
137− d.opened + d.merged + d.commits > 0 ? (
136+ d.opened + d.merged > 0 ? (
138137 <rect
139138 key={`p${i}`}
140139 x={x(i) - BAR / 2}
141− y={above(d.opened + d.merged + d.commits)}
140+ y={above(d.opened + d.merged)}
142141 width={BAR}
143− height={MID - above(d.opened + d.merged + d.commits)}
142+ height={MID - above(d.opened + d.merged)}
144143 rx={1.5}
145144 className="fill-fg-soft"
146145 />
225224 <th className="py-1 font-normal">{by === "team" ? "Team" : "Project"}</th>
226225 <th className="py-1 font-normal">Opened</th>
227226 <th className="py-1 font-normal">Merged</th>
228− <th className="py-1 font-normal">Pushed by agents</th>
229227 <th className="py-1 font-normal">Rework</th>
230228 <th className="py-1 font-normal">Reviews &amp; comments</th>
231229 <th className="py-1 font-normal">Agent sessions</th>
242240 <td className="py-1.5 text-fg">{row.label}</td>
243241 <td>{cell(last.opened, now.opened)}</td>
244242 <td>{cell(last.merged, now.merged)}</td>
245− <td>{cell(last.commits, now.commits)}</td>
246243 <td>{cell(last.reworked, now.reworked)}</td>
247244 <td>{cell(last.reviews, now.reviews)}</td>
248245 <td>{cell(last.sessions, now.sessions)}</td>
+10−14
141141 export function SpendFlow({ flow, sized }: { flow: FlowNode[]; sized: "spend" | "pulls" }) {
142142 const [path, setPath] = useState<string[]>([]);
143143 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);
148144 // 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];
150146 const chosen: FlowNode[] = [];
151− for (let level = 0; level < depth; level++) {
147+ for (let level = 0; level < 3; level++) {
152148 const nodes = columns[level] ?? [];
153149 const pick = nodes.find((node) => node.key === path[level]) ?? nodes[0];
154150 if (!pick) break;
155151 chosen.push(pick);
156− if (level < depth - 1) columns.push(pick.children);
152+ if (level < 2) columns.push(pick.children);
157153 }
158− const pulls = chosen[depth - 1]?.list ?? [];
154+ const pulls = chosen[2]?.list ?? [];
159155 const choose = (level: number, key: string) => setPath([...chosen.slice(0, level).map((node) => node.key), key]);
160156 const boxes = columns.map((nodes) => stack(nodes.map(weight)));
161157 const rowHeight = pulls.length > 0 ? HEIGHT / Math.max(pulls.length, 5) : 0;
166162 return (
167163 <div className="space-y-4">
168164 <Legend />
169− {/* Wide: the columns, joined. */}
165+ {/* Wide: the four columns, joined. */}
170166 <div className="hidden md:block">
171167 <div className="relative h-7">
172− {heads.map((head, column) => (
168+ {HEADS.map((head, column) => (
173169 <p key={head} className="absolute top-0 font-mono text-[0.6875rem] tracking-[0.14em] text-muted uppercase" style={{ left: `${left(column)}%` }}>
174170 {head}
175171 </p>
180176 {chosen.map((node, level) => {
181177 const index = columns[level]!.indexOf(node);
182178 const from = boxes[level]![index]!;
183− if (level < depth - 1) {
179+ if (level < 2) {
184180 const children = columns[level + 1]!;
185181 return slices(from, children.map(weight)).map((slice, i) => (
186182 <path key={`${level}-${i}`} d={band(level, slice, boxes[level + 1]![i]!)} className="fill-line-strong" opacity={0.45} />
213209 )}
214210 <div
215211 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 }}
217213 >
218214 {pulls.length === 0 ? (
219215 <p className="px-3 py-4 text-sm text-muted">No pull requests here.</p>
227223 <div className="space-y-5 md:hidden">
228224 {columns.map((nodes, level) => (
229225 <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>
231227 <div className="grid gap-2">
232228 {nodes.map((node) => (
233229 <NodeBox key={node.key} node={node} sized={sized} chosen={chosen[level]?.key === node.key} onChoose={() => choose(level, node.key)} />
236232 </div>
237233 ))}
238234 <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>
240236 <div className="rounded-xl border border-line bg-surface">
241237 {pulls.map((pull) => (
242238 <PullRow key={pull.key} pull={pull} style={{ height: 48 }} />
+11−2
4949 return (
5050 <span className={cn("inline-flex min-w-0 items-center gap-1", className)}>
5151 {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+ >
5356 <span
5457 className={cn(
5558 "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",
5766 )}
5867 >
5968 {repo.landed && <Check size={10} className="shrink-0" />}
+92−5
33
44 import type { Claim, ItemRepo, WorkItem, WorkspaceSession } from "@g1t/contracts";
55
6−import { boardOf, pullRepos, pullsByItem, shownStage, stageOf } from "./board.ts";
6+import { type ItemPull, boardOf, cappedNotes, pullRepos, pullsByItem, readPages, shownStage, stageOf } from "./board.ts";
77
88 const NOW = Date.parse("2026-10-17T12:00:00Z");
99 const DAY = 86_400_000;
6464 };
6565 }
6666
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+
6774 const claim = (number: number, who: string): Claim =>
6875 ({ id: `clm_${number}`, item: number, workspace: "acme", state: "active", actor: { id: "usr_9", username: who } }) as Claim;
6976
7279 assert.equal(stageOf(item(2, { assignees: ["ana"] }), {}, NOW), "progress");
7380 assert.equal(stageOf(item(3), { claimed: true }, NOW), "progress");
7481 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");
7784 // Landed in one target, not yet the other: Landing, even with a pull request open.
7885 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");
8087 // A touched repository's landing is not a target's.
8188 assert.equal(stageOf(item(8, { repos: [repo("acme/web", "touched", true), repo("acme/api", "target")] }), {}, NOW), "backlog");
8289 assert.equal(stageOf(item(9, { state: "closed", closedAt: new Date(NOW - 3 * DAY).toISOString() }), {}, NOW), "done");
95102 ];
96103 const board = boardOf(
97104 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" }]) },
99106 NOW,
100107 );
101108 assert.deepEqual(board.backlog.map((card) => card.item.number), [1]);
124131 { state: "closed", repos: [{ path: "acme/old" }] },
125132 // A repository deleted before work items kept no path.
126133 { 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 }] },
127136 ];
128137 assert.deepEqual(pullRepos(items), ["acme/web", "acme/api", "acme/repo-21"]);
129138 // However many there are: none is left out past a limit.
130139 const many = Array.from({ length: 30 }, (_, index) => ({ state: "open", repos: [{ path: `acme/r${index}` }] }));
131140 assert.equal(pullRepos(many).length, 30);
132141 });
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+});
+129−30
1212
1313 export const STAGE_INFO: Record<Stage, { label: string; hint: string }> = {
1414 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" },
1616 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" },
1919 };
2020
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. */
2222 export const DONE_DAYS = 14;
2323
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[]>;
2639
2740 /** Who or what is on a work item, for its card. */
2841 export type OnIt = {
3346 };
3447
3548 /**
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.
4162 */
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 {
4364 if (item.state === "closed") {
65+ if (item.reason === "not_planned") return null;
4466 const closed = item.closedAt ? Date.parse(item.closedAt) : 0;
4567 return now - closed <= DONE_DAYS * 86_400_000 ? "done" : null;
4668 }
4769 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";
4972 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";
5277 return "backlog";
5378 }
5479
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+};
5687
5788 export type Board = Record<Stage, BoardCard[]>;
5889
73104 seen.add(item.number);
74105 const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active");
75106 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);
79115 if (!stage) continue;
80116 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 });
82119 }
83120 for (const stage of STAGES) {
84121 const at = (card: BoardCard) => Date.parse(stage === "done" ? (card.item.closedAt ?? card.item.updatedAt) : card.item.updatedAt);
88125 }
89126
90127 /**
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.
95132 */
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[] {
97134 const seen = new Map<string, string>();
98135 for (const item of items) {
99136 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+ }
101141 }
102142 return [...seen.values()];
103143 }
104144
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 {
107154 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+ };
108159 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 });
111164 }
112165 return by;
113166 }
114167
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+
115214 /** The stage a phone shows, from the address: a known one, else the first with anything in it, else Backlog. */
116215 export function shownStage(asked: string | null, board: Board): Stage {
117216 if (asked && (STAGES as string[]).includes(asked)) return asked as Stage;
+8−1
33
44 import type { WorkSession, WorkspaceSession } from "@g1t/contracts";
55
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";
77
88 function listed(id: string, more: Partial<WorkspaceSession> = {}): WorkspaceSession {
99 return {
5454 assert.equal(runsIn(listed("h", { kind: "recorded", harness: null, agent: "cursor" })), "cursor, recorded");
5555 });
5656
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+
5764 test("Sessions keeps its filters in the address and drops what it does not know", () => {
5865 const filters = sessionsFilters(new URLSearchParams("repo=web&person=@bo&harness=codex&kind=bogus&state=recent"));
5966 assert.deepEqual(filters, { state: "recent", repo: "web", person: "bo", harness: "codex", kind: null, item: null });
+10−0
2626 return session.title?.trim() || session.pullTitle?.trim() || (session.pull != null ? `Pull request #${session.pull}` : "Untitled session");
2727 }
2828
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+
2939 /** What runs it, in a few words: the harness, g1t's agent, or the agent that recorded it. */
3040 export function runsIn(session: Pick<WorkspaceSession, "kind" | "harness" | "agent">): string {
3141 if (session.kind === "agent") return "g1t's agent";
+2−19
277277 author({ ownerId: "u1", ownerName: "ana", repoId: "r1", recent: 9, merged: 40 }),
278278 author({ ownerId: "u2", ownerName: "bo", repoId: "r1", firstMergedAt: recent, recent: 1, merged: 1 }),
279279 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 }),
281280 ];
282281 assert.deepEqual(
283282 newGround(authors, NOW).map((row) => [row.name, row.repoId, row.elsewhere]),
284283 [["bo", "r1", 12]],
285284 );
286− assert.deepEqual(silos(authors.slice(0, 3), NOW), [], "two people: every project rests on one of them, which says nothing");
287285 const [silo] = silos(authors, NOW);
288286 assert.equal(silo!.name, "ana");
289287 assert.equal(silo!.share, 0.9);
308306 found.map((s) => s.key),
309307 ["blocked", "stalled"],
310308 );
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.");
312310 assert.equal(
313311 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.",
320313 );
321314 });
322315
436429 "Codex is catching on: bo and cy started using it this month, after ana.",
437430 );
438431 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");
449432 });
+25−50
114114 reviewSeconds: 0,
115115 stalled: 0,
116116 sessionCostUsd: 0,
117− agentCommits: 0,
118117 });
119118
120119 /** Every week in `starts`, quiet ones too, for the repositories in `only` (every one when not given). */
132131 week.reviewSeconds += row.reviewSeconds;
133132 week.stalled += row.stalled;
134133 week.sessionCostUsd += row.sessionCostUsd ?? 0;
135− week.agentCommits += row.agentCommits ?? 0;
136134 }
137135 return starts.map((start) => byStart.get(start)!);
138136 }
174172 return week.agentMerged > 0 ? week.agentReworked / week.agentMerged : null;
175173 }
176174
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. */
193176 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;
195178 }
196179
197−/** Usage per agent change (merge or direct commit), in micros. */
180+/** Usage per agent merge, in micros. */
198181 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;
200183 }
201184
202185 // --- Words ------------------------------------------------------------------------
299282
300283 /**
301284 * 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.
305287 */
306288 export function pulse({ slug, weeks, spend }: PulseInput): PulseTile[] {
307289 const at = weeks.length - 1;
315297 return [
316298 {
317299 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",
324306 to: `/${slug}/-/overview?tab=landed`,
325307 },
326308 {
327309 key: "spend",
328− label: "Spend per agent change",
310+ label: "Spend per agent merge",
329311 value: dollars(perMerge[at] ?? null),
330312 delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false),
331313 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",
334316 to: `/${slug}/-/usage`,
335317 },
336318 {
664646 /**
665647 * A project where one person has done most of the recent work: their
666648 * 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).
669650 */
670651 export type Silo = {
671652 repoId: string;
677658 joining: { ownerId: string; name: string }[];
678659 };
679660
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[] {
684662 const byRepo = new Map<string, SignalAuthor[]>();
685663 for (const row of authors) byRepo.set(row.repoId, [...(byRepo.get(row.repoId) ?? []), row]);
686664 const found: Silo[] = [];
837815
838816 /** The headline when nothing stands out: still a sentence about the week. */
839817 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.";
842819 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.`;
844821 }
845822
846823 /** What Home shows: the pulse, and the findings with the one that leads. */
999976
1000977 // --- The rhythm of the work -------------------------------------------------------
1001978
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 };
1003980
1004981 export type RhythmRow = {
1005982 key: string;
1018995 export function rhythm(days: SignalDay[], list: string[], groups: Group[], max = 6): RhythmRow[] {
1019996 const rows = groups.map((group) => {
1020997 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 }]));
1022999 for (const row of days) {
10231000 const day = byDay.get(row.day);
10241001 if (!day || !ids.has(row.repoId)) continue;
10271004 day.reworked += row.reworked;
10281005 day.reviews += row.reviews;
10291006 day.sessions += row.sessions;
1030− day.commits += row.agentCommits ?? 0;
10311007 }
10321008 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);
10341010 return { key: group.key, label: group.label, note: group.note, to: group.to, days: out, total };
10351011 });
10361012 return rows
10481024 reworked: sum.reworked + day.reworked,
10491025 reviews: sum.reviews + day.reviews,
10501026 sessions: sum.sessions + day.sessions,
1051− commits: sum.commits + day.commits,
10521027 }),
1053− { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 },
1028+ { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0 },
10541029 );
10551030 }
10561031
10591034 const half = Math.floor(row.days.length / 2);
10601035 const last = rhythmSum(row.days.slice(0, half));
10611036 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;
10631038 if (prs(now) === 0 && prs(last) > 0) return "Quiet this week after a busy last week";
10641039 if (now.reviews === 0 && now.opened > 0) return `${now.opened} opened this week, no reviews yet`;
10651040 if (prs(now) > 1.5 * prs(last) && prs(now) >= 4) return "Busier than last week";
10661041 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`;
10681043 }
10691044
10701045 export type WaitingRow = WaitingPull & { repo: RepoPath; to: string };
+71−28
1−import { Bot, Columns3, Plus } from "lucide-react";
1+import { Bot, Columns3, GitMerge, Plus } from "lucide-react";
22 import { Link } from "react-router";
33
4−import type { Pull } from "@g1t/contracts";
4+import { LIST_PAGE } from "@g1t/contracts";
55
66 import type { Route } from "./+types/board";
77 import { HarnessMark, StateDot } from "../../../components/sessions";
1111 import { Hint } from "../../../components/ui/hint";
1212 import { RepoChips } from "../../../components/work-items";
1313 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";
1531 import { cn } from "../../../lib/cn";
1632 import { page } from "../../../lib/meta";
1733 import { isFailed, part } from "../../../lib/partial";
1935 import { workPages } from "../../../lib/repo-live";
2036 import { work } from "../../../lib/services.server";
2137 import { getViewer, roleIn, unwrap } from "../../../lib/session.server";
22−import { runsIn, sessionHref, workspaceSessionTitle } from "../../../lib/sessions";
38+import { onItemSessionTitle, runsIn, sessionHref } from "../../../lib/sessions";
2339 import { useWorkspaceLive } from "../../../lib/use-repo-live";
2440 import { itemPath, newItemPath, repoFilter, shortPath, workPath } from "../../../lib/work-items";
2541
3551 const repoName = query.get("repo")?.trim() || null;
3652 const repo = repoName ? repoFilter(workspace, repoName) : null;
3753 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.
3957 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"),
4260 part(work.listClaims(workspace, viewer, { state: "active", repo: repo ?? undefined }), "Claims"),
4361 part(work.listWorkspaceSessions(workspace, viewer, { shown: "live", sessionsOnly: true, repo: repoName ?? undefined, limit: 200 }), "Live sessions"),
4462 ]);
4563 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);
6490 return {
6591 workspace,
6692 member,
6894 repo: repoName,
6995 board,
7096 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.
7198 unavailable: [
7299 ...[closed, claims, live].filter(isFailed).map((failed) => failed.what),
73100 ...(unread.length === 0 ? [] : [unread.length === 1 ? `Pull requests in ${unread[0]}` : `Pull requests in ${unread.length} repositories`]),
74101 ],
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)] }),
75104 live: sessions.length,
76105 };
77106 }
93122 </span>
94123 </Hint>
95124 )}
125+ {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>}
96126 {sessions.slice(0, 2).map((session) => {
97127 const to = sessionHref(workspace, session);
98128 const label = (
103133 </span>
104134 );
105135 return (
106− <Hint key={session.id} label={`${workspaceSessionTitle(session)} · ${runsIn(session)}`}>
136+ <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}>
107137 {to ? (
108138 <Link to={to} className="min-w-0 hover:text-fg">
109139 {label}
136166 {item.title} <span className="font-normal text-faint">#{item.number}</span>
137167 </span>
138168 </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+ )}
140182 <OnIt card={card} workspace={workspace} />
141183 </Card>
142184 </li>
172214 * stages that wraps rather than scrolling sideways.
173215 */
174216 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;
176218 const total = STAGES.reduce((sum, one) => sum + board[one].length, 0);
177219 const query = (more: Record<string, string>) => `?${new URLSearchParams({ ...(repo ? { repo } : {}), ...more }).toString()}`;
178220 useWorkspaceLive({ workspace, member, match: workPages.list() });
197239 )}
198240 </div>
199241 {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>}
200243
201244 {total === 0 ? (
202245 <div className="mt-5">
+13−4
342342 return (
343343 <li className="px-3 py-3 sm:px-4">
344344 <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 ? (
346353 <Link to={`/${repo.path}`} className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
347354 {repo.path}
348355 </Link>
535542 const pullHref = pullHrefs(repoRows.map(({ repo, pulls: repoPulls }) => ({ path: repo.path, pulls: repoPulls })));
536543 const failureBase = `/${originOf(item.repos)?.path || workspace}`;
537544 // 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);
538547 const workIn = missing[0] ?? item.repos.find((repo) => repo.role === "target")?.path ?? originOf(item.repos)?.path ?? null;
539548 const linkError = actionData && "action" in actionData && actionData.action === "link" ? actionData.error : null;
540549 const sideError = actionData && "action" in actionData && ["blocked-by", "start-session", "react", "link", "open-pull"].includes(String(actionData.action));
922931 <div className="mt-3">
923932 <CopyLine text={reference} />
924933 </div>
925− {viewer && item.repos.length > 0 ? (
934+ {viewer && openable.length > 0 ? (
926935 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
927936 <input type="hidden" name="action" value="open-pull" />
928937 <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p>
929938 <SelectField
930939 name="repo"
931940 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()})` }))}
934943 />
935944 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
936945 <div className="*:w-full">
+9−1
16791679 pub repo_id: Option<String>,
16801680 }
16811681
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>>`.
16831684 #[derive(Debug, Serialize, Deserialize)]
16841685 pub struct ListPullsArgs {
16851686 pub repo: RepoPath,
16951696 /// Only pull requests into this branch.
16961697 #[serde(default)]
16971698 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>,
16981706 }
16991707
17001708 /// `pulls_for_repos`: the newest open and the newest closed pull requests
+15−3
6161 /// A pull request or commit that closes it landed in this repository.
6262 #[serde(default)]
6363 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,
6468 }
6569
6670 /// What resolved a work item in one repository: a pull request merged, or
247251 pub milestone: Option<u32>,
248252 }
249253
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
252256 /// repository's), only those linked to it, in any role or in `role`.
253257 /// Returns `Outcome<Vec<WorkItem>>`.
254258 #[derive(Debug, Serialize, Deserialize)]
272276 /// Only those assigned to this username.
273277 #[serde(default)]
274278 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>,
275287 }
276288
277289 /// `get_work_item` (`Outcome<WorkItemDetail>`), `get_work`
450462 use super::*;
451463
452464 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 }
454466 }
455467
456468 #[test]
+0−5
5858 /// What the harness sessions started in the week cost, as they
5959 /// reported it (estimated at list price from their token usage).
6060 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,
6461 }
6562
6663 /// One repository's UTC day. Days with nothing in them are left out.
7976 pub reviews: u32,
8077 /// Harness sessions started.
8178 pub sessions: u32,
82− /// Commits agent sessions pushed outside any pull request.
83− pub agent_commits: u32,
8479 }
8580
8681 /// An open pull request no one has reviewed yet, oldest first.
+4−1
493493 label: filter.label ?? null,
494494 milestone: filter.milestone ?? null,
495495 assignee: filter.assignee ?? null,
496+ closed_since: filter.closedSince ?? null,
497+ before: filter.before ?? null,
496498 }),
497499 getWorkItem: (workspace, number, viewer) => call("get_work_item", { workspace, number, viewer }),
498500 updateWorkItem: (actor, workspace, number, input) =>
570572 updateSettings: (actor, repo, settings) =>
571573 call("update_settings", { actor, repo, settings }),
572574 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 }),
574577 deliveryInsights: (repoIds, viewer, from, until) => call("delivery_insights", { repoIds, viewer, from, until }),
575578 workspaceSignals: (args, viewer) => call("workspace_signals", { ...args, viewer }),
576579 pullsForRepos: (repoIds, viewer, limit) => call("pulls_for_repos", { repoIds, viewer, limit }),
+24−6
3434 legacyNumber: number | null;
3535 /** A pull request or commit that closes it landed in this repository. */
3636 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;
3742 };
3843
3944 /** What resolved a work item in one repository: a pull request merged, or a commit pushed. */
871876 blocked_by?: number[];
872877 };
873878
879+/** How many `listWorkItems` and `listPulls` give at most in one call. */
880+export const LIST_PAGE = 100;
881+
874882 /** The filters on `listWorkItems`. */
875883 export type WorkItemFilter = {
876884 /** Only those linked to this repository (in any role, or in `role`). */
881889 milestone?: number;
882890 /** Only those assigned to this username. */
883891 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;
884896 };
885897
886898 export type OpenPullInput = {
942954 * meant to land.
943955 */
944956 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). */
946958 listWorkItems(workspace: string, viewer: Viewer, filter?: WorkItemFilter): Promise<Result<WorkItem[]>>;
947959 getWorkItem(workspace: string, number: number, viewer: Viewer): Promise<Result<WorkItemDetail>>;
948960 /** Its owner, or someone with the Triage role on it, may. */
10891101 * branch, one ready for review.
10901102 */
10911103 openPull(actor: User, repo: RepoPath, input: OpenPullInput): Promise<Result<Pull>>;
1092− /** Newest first. */
1104+ /** Newest first, at most [`LIST_PAGE`] (`before` pages on). */
10931105 listPulls(
10941106 repo: RepoPath,
10951107 viewer: Viewer,
10961108 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+ },
10981118 ): Promise<Result<Pull[]>>;
10991119 /**
11001120 * Delivery for up to 100 repositories over UTC days `from` through
19081928 reviewSeconds: number;
19091929 stalled: number;
19101930 sessionCostUsd: number;
1911− /** Commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. */
1912− agentCommits: number;
19131931 };
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 };
19151933 /** An open pull request no one has reviewed, oldest first; `blocks`: the open work items waiting on its work item. */
19161934 export type WaitingPull = { repoId: string; number: number; title: string; createdAt: string; agent: boolean; draftSeconds: number | null; item: number | null; blocks: number[] };
19171935 export type SignalPull = {
+2−0
11491149 label: None,
11501150 milestone: None,
11511151 base: None,
1152+ before: None,
1153+ merged_since: None,
11521154 },
11531155 )
11541156 .await
+3−1
767767 label: None,
768768 milestone: None,
769769 assignee: None,
770+ closed_since: None,
771+ before: None,
770772 },
771773 )
772774 .await?;
788790 let pulls: Outcome<Vec<Pull>> = g1t_kit::call(
789791 &self.work,
790792 "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 },
792794 )
793795 .await?;
794796 let Outcome::Ok(pulls) = pulls else { return Ok(()) };
+1−2
2323 sha2 = "0.10"
2424
2525 [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"] }
2827
2928 # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the
3029 # time.
+9−0
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');
+1−1
445445 fn a_repository_already_linked_is_not_suggested() {
446446 let candidates = vec![("rep_web".to_owned(), "acme/web".to_owned())];
447447 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 };
449449 let item = item("Fix web", "", &[], vec![linked]);
450450 assert!(suggest_targets(&item, &candidates, &[], &[]).is_empty(), "{role:?}");
451451 }
+8−2
1515 use crate::{UNVERIFIED, Work};
1616
1717 /// 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.
1919 pub(crate) const REMEMBER_REPO: &str = "INSERT INTO repo_workspaces (repo_id, workspace, path, private)
2020 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";
2228
2329 /// Linking a work item (`?1`) that a change in repository `?2` is part of,
2430 /// at `?3`, by `?4`, unless it is linked already.
+66−1
2828 use g1t_kit::now_ms;
2929 use serde::Deserialize;
3030 use worker::Result;
31+use worker::wasm_bindgen::JsValue;
3132
3233 use crate::Work;
3334 use crate::optional;
173174 /// labels and old numbers unmerged.
174175 pub(crate) const DELETED_REPOS_JOB: &str = "deleted_repos";
175176
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+
176183 /// Every label a workspace's work items or pull requests carry that the
177184 /// workspace does not have, as a label (gray, no description) made at
178185 /// `?1`: one a repository had that was never merged. Run again, it adds
298305 return Ok(WorkMigration { done: true, ..WorkMigration::default() });
299306 };
300307 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;
302313 }
303314 let mut cursor = job.cursor;
304315 let mut recorded = 0;
347358 .map(|repo| (repo.id.clone(), repo.namespace.clone(), repo.name.clone(), repo.is_private))
348359 .collect();
349360 recorded += self.record_repos(&repos).await?;
361+ // Recording one says it is there; these are deleted.
362+ self.mark_deleted(&page).await?;
350363 match page.next {
351364 Some(next) => {
352365 self.db
370383 Ok(WorkMigration { done: false, repos: recorded, ..WorkMigration::default() })
371384 }
372385
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+
373438 /// Records repositories (id, workspace, name, private) where they are.
374439 async fn record_repos(&self, repos: &[(String, String, String, bool)]) -> Result<u32> {
375440 if repos.is_empty() {
+1−0
6262 mod tests;
6363
6464 pub(crate) use labels::Labeled;
65+pub(crate) use links::MARK_DELETED;
6566 pub(crate) use landing::{Landing, PastLanding};
6667 pub(crate) use migration::{DELETED, RENAMED, TRANSFERRED};
6768 pub(crate) use reading::{held_rows, select};
+91−3
1616 /// What `list_work_items` reads: `?1` the workspace, then the filters, and
1717 /// `?8` whether the viewer is a member (else only work items filed in a
1818 /// 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.
2021 const LIST_SQL: &str = "SELECT {ITEM_COLUMNS} FROM work_items
2122 WHERE workspace = ?1 AND (?2 IS NULL OR state = ?2)
2223 AND (?3 IS NULL OR EXISTS (SELECT 1 FROM json_each(work_items.labels) WHERE json_each.value = ?3))
2728 AND (?8 = 1 OR EXISTS (SELECT 1 FROM work_item_repos o JOIN repo_workspaces rw ON rw.repo_id = o.repo_id
2829 WHERE o.item_id = work_items.id AND o.role = 'origin'
2930 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";
3134
3235 /// The work items matching `condition` (on `work_items`), with everything
3336 /// they are read with.
216219 u32::from(member).into(),
217220 serde_json::to_string(&granted)?.into(),
218221 LIST_PAGE.into(),
222+ optional(&a.closed_since.map(|at| at.trim().to_owned()).filter(|at| !at.is_empty())),
223+ optional_number(a.before),
219224 ])?;
220225 let rows = self.timing.db(1, query.all()).await?.results::<ItemRow>()?;
221226 Ok(Outcome::Ok(
307312
308313 #[cfg(test)]
309314 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+ }
311399
312400 #[test]
313401 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…