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.

Commit

Merge pull request #164 from flagon-io/board-fixes

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

syntaqxcommitted Parentse3b707bd55d5abBrowse files
28 files+662−1150/28 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?;
+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 }
+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";
+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]
+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 }),
+23−3
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
+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(()) };
+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() {
+5−1
2626 WHERE b.number IS NOT NULL ORDER BY j.key)) AS blocked_numbers,
2727 (SELECT json_group_array(json_object('repo_id', l.repo_id, 'path', COALESCE(rw.path, l.path, ''), 'role', l.role,
2828 'legacy_number', l.legacy_number, 'landed', l.landed_at IS NOT NULL, 'workspace', rw.workspace,
29− 'private', rw.private))
29+ 'private', rw.private, 'deleted', rw.deleted_at IS NOT NULL))
3030 FROM work_item_repos l LEFT JOIN repo_workspaces rw ON rw.repo_id = l.repo_id
3131 WHERE l.item_id = work_items.id) AS links";
3232
8888 workspace: Option<String>,
8989 #[serde(default)]
9090 private: Option<u8>,
91+ /// The repository is deleted, not yet purged (`repo_workspaces.deleted_at`).
92+ #[serde(default)]
93+ deleted: u8,
9194 }
9295
9396 /// A resolution as stored.
227230 role: link.role,
228231 legacy_number: link.legacy_number,
229232 landed: link.landed != 0,
233+ deleted: link.deleted != 0,
230234 })
231235 .collect();
232236 sort_repos(&mut repos);
+2−1
309309 .unwrap();
310310 migrate_from(&db, "0052_work_floors.sql");
311311 assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters"), [("acme".to_owned(), Some(9))]);
312− assert_eq!(strings(&db, "SELECT name FROM work_jobs WHERE done_at IS NULL"), ["deleted_repos"]);
312+ // The jobs after it wait their turn: the one 0052 adds, and 0055's.
313+ assert_eq!(strings(&db, "SELECT name FROM work_jobs WHERE done_at IS NULL ORDER BY name"), ["deleted_marks", "deleted_repos"]);
313314 run(&db, super::migration::RAISE_FLOORS, &[&"2026-10-18T12:00:00Z"]);
314315 assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters"), [("acme".to_owned(), Some(9))]);
315316 }
+12−7
11371137 .map(|label| label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase())
11381138 .filter(|label| !label.is_empty());
11391139 let milestone = a.milestone;
1140+ let before = a.before;
1141+ let merged_since = a.merged_since.map(|at| at.trim().to_owned()).filter(|at| !at.is_empty());
11401142 let read = |repo_id: String| {
11411143 let label = label.clone();
1144+ let merged_since = merged_since.clone();
11421145 async move {
11431146 let query = self
11441147 .db
11451148 .prepare(format!(
1146− "SELECT {PULL_COLUMNS} FROM pulls WHERE repo_id = ? {filter}
1147− AND (? IS NULL OR EXISTS
1148− (SELECT 1 FROM json_each(pulls.labels) WHERE json_each.value = ?))
1149− AND (? IS NULL OR milestone = ?)
1150− ORDER BY number DESC LIMIT ?"
1149+ "SELECT {PULL_COLUMNS} FROM pulls WHERE repo_id = ?1 {filter}
1150+ AND (?2 IS NULL OR EXISTS
1151+ (SELECT 1 FROM json_each(pulls.labels) WHERE json_each.value = ?2))
1152+ AND (?3 IS NULL OR milestone = ?3)
1153+ AND (?5 IS NULL OR number < ?5)
1154+ AND (?6 IS NULL OR (status = 'merged' AND merged_at >= ?6))
1155+ ORDER BY number DESC LIMIT ?4"
11511156 ))
11521157 .bind(&[
11531158 repo_id.into(),
11541159 optional(&label),
1155− optional(&label),
1156− optional_number(milestone),
11571160 optional_number(milestone),
11581161 LIST_PAGE.into(),
1162+ optional_number(before),
1163+ optional(&merged_since),
11591164 ])?;
11601165 self.timing.db(1, query.all()).await?.results::<PullRow>()
11611166 }
+11−1
179179 }
180180
181181 /// `repo.deleted` and `repo.archived`: its runs stop (the runner stops
182− /// their sandboxes, see [`RunsInRepoArgs`]). Rows are kept, for a restore.
182+ /// their sandboxes, see [`RunsInRepoArgs`]). Rows are kept, for a
183+ /// restore; a deleted one is marked so in `repo_workspaces`.
183184 async fn stop_runs_in(&self, repo_id: &str) -> Result<()> {
184185 let now = rfc3339(now_ms());
185186 self.db
198199 }
199200 if let Some((repo_id, _, _)) = stopping(event) {
200201 self.stop_runs_in(&repo_id).await?;
202+ // Its work items' links say it is deleted, until it is restored or purged.
203+ if event.kind == "repo.deleted" {
204+ let at = rfc3339(now_ms());
205+ self.db
206+ .prepare(crate::items::MARK_DELETED)
207+ .bind(&[repo_id.as_str().into(), at.as_str().into()])?
208+ .run()
209+ .await?;
210+ }
201211 return Ok(true);
202212 }
203213 if event.kind != "branch.renamed" {

Comments

Sign in to comment.