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

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

A not-found answer for a deleted repository's pull requests showed the "could not be loaded" banner for good; only a thrown or 5xx read counts as unread now, and deleted repositories are not read at all. Open work items and each repository's pull requests are read page by page, Done asks for items closed in the last 14 days by close date, and where a ceiling remains the board says so. A pull request counts for every item it is for, closes or is part of, and merged ones from the last 14 days keep an open item In progress (part of it) or Landing (it closes it and a target still misses it). Closed as not planned stays out of Done. Cards show "+N" past three people, "N merged", deleted repositories struck through, and an untitled session as "On pull request #N" rather than a number that reads as the item's. The phone app's Board leaves not planned out of Done too. Closes #161

G1t-Session:
ses_01m5780qevefmb5pmg2kcjsgba
syntaqxcommitted Parentd169844Browse files
10 files+395−900/10 viewed
+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">
+14−5
341341 return (
342342 <li className="px-3 py-3 sm:px-4">
343343 <div className="flex min-w-0 items-center gap-2">
344− {repo.path ? (
344+ {repo.path && repo.deleted ? (
345+ // Deleted, and restorable until it is purged: its name, nothing to open.
346+ <Hint label={`${repo.path} was deleted. It can be restored until it is purged.`}>
347+ <span className="min-w-0 truncate font-mono text-sm text-muted">
348+ {repo.path} <span className="font-sans text-xs text-faint">deleted</span>
349+ </span>
350+ </Hint>
351+ ) : repo.path ? (
345352 <Link to={`/${repo.path}`} className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent">
346353 {repo.path}
347354 </Link>
534541 const pullHref = pullHrefs(repoRows.map(({ repo, pulls: repoPulls }) => ({ path: repo.path, pulls: repoPulls })));
535542 const failureBase = `/${originOf(item.repos)?.path || workspace}`;
536543 // Where a pull request or a session would be opened: a target still missing, else where it was filed.
537− const workIn = missing[0] ?? item.repos.find((repo) => repo.role === "target")?.path ?? originOf(item.repos)?.path ?? null;
544+ // A deleted repository takes no pull request.
545+ const openable = item.repos.filter((repo) => repo.path && !repo.deleted);
546+ const workIn = missing[0] ??item.repos.find((repo) => repo.role === "target")?.path ?? originOf(item.repos)?.path ?? null;
538547 const linkError = actionData && "action" in actionData && actionData.action === "link" ? actionData.error : null;
539548 const sideError = actionData && "action" in actionData && ["blocked-by", "start-session", "react", "link", "open-pull"].includes(String(actionData.action));
540549
920929 <div className="mt-3">
921930 <CopyLine text={reference} />
922931 </div>
923− {viewer && item.repos.length > 0 ? (
932+ {viewer && openable.length > 0 ? (
924933 <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
925934 <input type="hidden" name="action" value="open-pull" />
926935 <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p>
927936 <SelectField
928937 name="repo"
929938 aria-label="Repository"
930− defaultValue={workIn ?? item.repos[0]!.path}
931− options={item.repos.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))}
939+ defaultValue={openable.some((repo) => repo.path === workIn) ? workIn! : openable[0]!.path}
940+ options={openable.map((repo) => ({ value: repo.path, label: `${repo.path} (${ROLE_LABELS[repo.role].toLowerCase()})` }))}
932941 />
933942 <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} />
934943 <div className="*:w-full">

Comments

Sign in to comment.