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
| 13 | 13 | | Stage | Holds the work items | | |
| 14 | 14 | | --- | --- | | |
| 15 | 15 | | **Backlog** | Open, and nobody is on it. | | |
| 16 | − | | **In progress** | Claimed or assigned, with a live session on it, or with a draft pull request. | | |
| 16 | + | | **In progress** | Claimed or assigned, with a live session on it, with a draft pull request, or with a pull request merged that did not close it. | | |
| 17 | 17 | | **In review** | With a pull request open for review. | | |
| 18 | − | | **Landing** | Landed in some of its target repositories, still missing in others. | | |
| 19 | − | | **Done** | Closed in the last 14 days. | | |
| 18 | + | | **Landing** | Landed in some of its target repositories and still missing in others, or with a pull request that closes it merged while a target still misses it. | | |
| 19 | + | | **Done** | Closed as completed in the last 14 days. | | |
| 20 | 20 | ||
| 21 | 21 | A work item is in the first stage that holds for it, in the order | |
| 22 | 22 | **Done**, **Landing**, **In review**, **In progress**, **Backlog**. So a | |
| 23 | 23 | work item with one pull request in review and another still a draft is | |
| 24 | 24 | **In review**, and one that has landed in one of its two targets is | |
| 25 | 25 | **Landing** whatever else is going on. A work item closed more than 14 | |
| 26 | − | days ago leaves the Board; the Work list's **Closed** still has it. | |
| 26 | + | days ago leaves the Board, and so does one closed as not planned (a | |
| 27 | + | duplicate, say), since it did not ship. The Work list's **Closed** still | |
| 28 | + | has both. | |
| 29 | + | ||
| 30 | + | ## Which pull requests count | |
| 27 | 31 | ||
| 32 | + | A pull request counts for a work item when it is for that work item, | |
| 33 | + | when it closes it (`Fixes #12` in its title or description), or when it | |
| 34 | + | is part of it (`Part of #12`). The Board reads each repository's open | |
| 35 | + | and draft pull requests, and those merged in the last 14 days, in every | |
| 36 | + | repository an open work item is linked to. | |
| 37 | + | ||
| 38 | + | - **Merged, and it closes the work item**, but a target repository still | |
| 39 | + | misses it (it merged into a repository that is not a target): the work | |
| 40 | + | item is **Landing** until it lands in every target. | |
| 41 | + | - **Merged as part of it**, or merged with no target left to land in (for | |
| 42 | + | example into a branch that is not the default): the work item stays | |
| 43 | + | **In progress**, since there is more to do before it closes. | |
| 44 | + | - Its card says how many of its pull requests merged in the last 14 days, | |
| 45 | + | such as "1 merged". | |
| 46 | + | ||
| 28 | 47 | ## How a work item moves | |
| 29 | 48 | ||
| 30 | 49 | A typical work item goes left to right as the work happens: | |
| ⋯ | |||
| 37 | 56 | (`g1t pr create --item 12` opens a draft). A pull request a live session | |
| 38 | 57 | is on counts too. | |
| 39 | 58 | 3. **In review.** Its pull request is marked ready for review. | |
| 40 | − | 4. **Landing.** For a work item with several targets: its pull request | |
| 41 | − | merged in one repository and the others are still to land. A work item | |
| 42 | − | with no target, or one, skips this stage: it closes as soon as it lands. | |
| 43 | − | 5. **Done.** It closed: every target landed, or someone closed it. See | |
| 44 | − | [closing work items](/work/closing/). | |
| 59 | + | 4. **Landing.** For a work item with targets still to land: its pull | |
| 60 | + | request merged in one target and the others are still to land, or it | |
| 61 | + | merged into a repository that is not a target. A work item whose pull | |
| 62 | + | request lands in its only target, or one with no target, skips this | |
| 63 | + | stage: it closes as soon as it lands. | |
| 64 | + | 5. **Done.** It closed as completed: every target landed, or someone | |
| 65 | + | closed it. See [closing work items](/work/closing/). | |
| 45 | 66 | ||
| 46 | 67 | It moves back as well: a claim released or expired with nobody else on it | |
| 47 | 68 | and no pull request sends it back to **Backlog**, and reopening a work item | |
| ⋯ | |||
| 52 | 73 | ||
| 53 | 74 | Each column lists its work items most recently changed first, and **Done** | |
| 54 | 75 | most recently closed first. Each card shows the work item's title, number | |
| 55 | − | and repositories, and who is on it: up to three people (its assignees, | |
| 56 | − | whoever claimed it, whoever runs a session on it), up to two of its live | |
| 57 | − | sessions, then "+2" for the rest. Choose a card to open the work item. | |
| 76 | + | and repositories, how many of its pull requests merged lately, and who is | |
| 77 | + | on it: up to three people (its assignees, whoever claimed it, whoever runs | |
| 78 | + | a session on it) and up to two of its live sessions, each followed by a | |
| 79 | + | count of the rest, such as "+4" for seven people. A session with no title | |
| 80 | + | of its own is named by what it is on, such as "On pull request #158". | |
| 81 | + | Choose a card to open the work item. | |
| 82 | + | ||
| 83 | + | A repository that was deleted and can still be restored shows struck | |
| 84 | + | through on its cards, and the Board reads nothing from it. If a | |
| 85 | + | repository's pull requests could not be read at all, the Board says so | |
| 86 | + | above the columns: its work items may stand a column early until it can. | |
| 58 | 87 | ||
| 88 | + | The Board reads up to 1,000 open work items, the newest first, up to 300 | |
| 89 | + | open and 300 recently merged pull requests in each repository, and up to | |
| 90 | + | 100 work items for **Done**. When there are more, it says so above the | |
| 91 | + | columns, such as "Showing the newest 1,000 open work items." | |
| 92 | + | ||
| 59 | 93 | The Board follows the work as it happens, without reloading, and checks | |
| 60 | 94 | again every 30 seconds while a session is live, since a session going idle | |
| 61 | 95 | sends no event. | |
| ⋯ | |||
| 65 | 99 | - **On a phone** the Board shows one stage at a time, with a button for | |
| 66 | 100 | each and how many it holds; the address keeps it as `?stage=landing`. | |
| 67 | 101 | - **In the phone app**, **Board** at the top of Work shows the stages one | |
| 68 | − | under another, with a chip for each to show one alone. | |
| 102 | + | under another, with a chip for each to show one alone. It reads only | |
| 103 | + | open pull requests for now, so a work item whose pull request merged | |
| 104 | + | without closing it is where its claims and sessions put it. | |
| 69 | 105 | ||
| 70 | 106 | **New work item** is on the Board too. | |
| 57 | 57 | assert.equal(stageOf(item(1, { state: "closed", closed_at: new Date(NOW - 3 * DAY).toISOString() }), none), "done"); | |
| 58 | 58 | assert.equal(stageOf(item(1, { state: "closed", closed_at: new Date(NOW - 15 * DAY).toISOString() }), none), null); | |
| 59 | 59 | assert.equal(stageOf(item(1, { state: "closed", closed_at: null }), none), null); | |
| 60 | + | // Closed as not planned did not ship. | |
| 61 | + | assert.equal(stageOf(item(1, { state: "closed", reason: "not_planned", closed_at: new Date(NOW - DAY).toISOString() }), none), null); | |
| 60 | 62 | // Done wins over everything else. | |
| 61 | 63 | const repos = [target("acme/web", true), target("acme/api", false)]; | |
| 62 | 64 | assert.equal(stageOf(item(1, { state: "closed", repos, closed_at: new Date(NOW - DAY).toISOString() }), { ...none, pulls: [{ item: 1, status: "open" }] }), "done"); |
| 3 | 3 | ||
| 4 | 4 | /** | |
| 5 | 5 | * Work's board: where each work item stands as it moves, read from the | |
| 6 | − | * item, its pull requests and the sessions on it. Done is closed in the | |
| 7 | − | * last two weeks; Landing has landed in some targets but not all; In | |
| 6 | + | * item, its pull requests and the sessions on it. Done is closed as | |
| 7 | + | * completed in the last two weeks (not planned is left out); Landing has landed in some targets but not all; In | |
| 8 | 8 | * review has an open pull request; In progress has someone on it (an | |
| 9 | 9 | * assignee, a claim, a live session) or a draft; Backlog has nobody. | |
| 10 | 10 | * The first that holds, in that order, is its stage. | |
| ⋯ | |||
| 32 | 32 | /** How long a closed item stays in Done. */ | |
| 33 | 33 | export const DONE_DAYS = 14; | |
| 34 | 34 | ||
| 35 | − | export type BoardItem = Pick<ApiWorkItem, "number" | "state" | "assignees" | "repos" | "closed_at"> & { | |
| 36 | − | /** Someone has claimed it. */ | |
| 37 | − | claimed?: boolean; | |
| 38 | − | }; | |
| 35 | + | export type BoardItem = Pick<ApiWorkItem, "number" | "state" | "assignees" | "repos" | "closed_at"> & | |
| 36 | + | Partial<Pick<ApiWorkItem, "reason">> & { | |
| 37 | + | /** Someone has claimed it. */ | |
| 38 | + | claimed?: boolean; | |
| 39 | + | }; | |
| 39 | 40 | ||
| 40 | 41 | /** A pull request for a work item, by the item's number in the workspace. */ | |
| 41 | 42 | export type BoardPull = { item: number | null; status: PullStatus }; | |
| ⋯ | |||
| 57 | 58 | export function stageOf(item: BoardItem, context: BoardContext): Stage | null { | |
| 58 | 59 | const now = context.now ?? Date.now(); | |
| 59 | 60 | if (item.state === "closed") { | |
| 61 | + | // Closed as not planned (a duplicate, say) did not ship: not Done. | |
| 62 | + | if (item.reason === "not_planned") return null; | |
| 60 | 63 | const closed = item.closed_at ? Date.parse(item.closed_at) : NaN; | |
| 61 | 64 | return !Number.isNaN(closed) && now - closed <= DONE_DAYS * 86_400_000 ? "done" : null; | |
| 62 | 65 | } | |
| 49 | 49 | return ( | |
| 50 | 50 | <span className={cn("inline-flex min-w-0 items-center gap-1", className)}> | |
| 51 | 51 | {shown.map((repo) => ( | |
| 52 | − | <Hint key={repo.repoId} label={`${repo.path || "A deleted repository"}: ${ROLE_LABELS[repo.role].toLowerCase()}${repo.landed ? ", landed" : ""}`}> | |
| 52 | + | <Hint | |
| 53 | + | key={repo.repoId} | |
| 54 | + | label={`${repo.path || "A deleted repository"}${repo.deleted ? ", deleted" : ""}: ${ROLE_LABELS[repo.role].toLowerCase()}${repo.landed ? ", landed" : ""}`} | |
| 55 | + | > | |
| 53 | 56 | <span | |
| 54 | 57 | className={cn( | |
| 55 | 58 | "inline-flex max-w-32 min-w-0 items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[0.6875rem] leading-4", | |
| 56 | − | repo.landed ? "border-merged/40 text-merged" : repo.role === "target" ? "border-line-strong text-fg-soft" : "border-line text-muted", | |
| 59 | + | repo.deleted | |
| 60 | + | ? "border-dashed border-line text-faint line-through" | |
| 61 | + | : repo.landed | |
| 62 | + | ? "border-merged/40 text-merged" | |
| 63 | + | : repo.role === "target" | |
| 64 | + | ? "border-line-strong text-fg-soft" | |
| 65 | + | : "border-line text-muted", | |
| 57 | 66 | )} | |
| 58 | 67 | > | |
| 59 | 68 | {repo.landed && <Check size={10} className="shrink-0" />} |
| 3 | 3 | ||
| 4 | 4 | import type { Claim, ItemRepo, WorkItem, WorkspaceSession } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { boardOf, pullRepos, pullsByItem, shownStage, stageOf } from "./board.ts"; | |
| 6 | + | import { type ItemPull, boardOf, cappedNotes, pullRepos, pullsByItem, readPages, shownStage, stageOf } from "./board.ts"; | |
| 7 | 7 | ||
| 8 | 8 | const NOW = Date.parse("2026-10-17T12:00:00Z"); | |
| 9 | 9 | const DAY = 86_400_000; | |
| ⋯ | |||
| 64 | 64 | }; | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | /** Pull requests of these statuses, each its own, closing the item unless `part`. */ | |
| 68 | + | const pr = (...statuses: (ItemPull["status"] | `part:${ItemPull["status"]}`)[]): ItemPull[] => | |
| 69 | + | statuses.map((status, index) => { | |
| 70 | + | const part = status.startsWith("part:"); | |
| 71 | + | return { key: `acme/web#${index + 100}`, status: (part ? status.slice(5) : status) as ItemPull["status"], part }; | |
| 72 | + | }); | |
| 73 | + | ||
| 67 | 74 | const claim = (number: number, who: string): Claim => | |
| 68 | 75 | ({ id: `clm_${number}`, item: number, workspace: "acme", state: "active", actor: { id: "usr_9", username: who } }) as Claim; | |
| 69 | 76 | ||
| ⋯ | |||
| 72 | 79 | assert.equal(stageOf(item(2, { assignees: ["ana"] }), {}, NOW), "progress"); | |
| 73 | 80 | assert.equal(stageOf(item(3), { claimed: true }, NOW), "progress"); | |
| 74 | 81 | assert.equal(stageOf(item(4), { live: true }, NOW), "progress"); | |
| 75 | − | assert.equal(stageOf(item(5), { pulls: ["draft"] }, NOW), "progress"); | |
| 76 | − | assert.equal(stageOf(item(6, { assignees: ["ana"] }), { pulls: ["draft", "open"] }, NOW), "review"); | |
| 82 | + | assert.equal(stageOf(item(5), { pulls: pr("draft") }, NOW), "progress"); | |
| 83 | + | assert.equal(stageOf(item(6, { assignees: ["ana"] }), { pulls: pr("draft", "open") }, NOW), "review"); | |
| 77 | 84 | // Landed in one target, not yet the other: Landing, even with a pull request open. | |
| 78 | 85 | const landing = item(7, { repos: [repo("acme/web", "target", true), repo("acme/api", "target")] }); | |
| 79 | − | assert.equal(stageOf(landing, { pulls: ["open"] }, NOW), "landing"); | |
| 86 | + | assert.equal(stageOf(landing, { pulls: pr("open") }, NOW), "landing"); | |
| 80 | 87 | // A touched repository's landing is not a target's. | |
| 81 | 88 | assert.equal(stageOf(item(8, { repos: [repo("acme/web", "touched", true), repo("acme/api", "target")] }), {}, NOW), "backlog"); | |
| 82 | 89 | assert.equal(stageOf(item(9, { state: "closed", closedAt: new Date(NOW - 3 * DAY).toISOString() }), {}, NOW), "done"); | |
| ⋯ | |||
| 95 | 102 | ]; | |
| 96 | 103 | const board = boardOf( | |
| 97 | 104 | items, | |
| 98 | − | { claims: [claim(2, "cy")], sessions: [live(3, { pull: 9, pullStatus: "open" }), live(1, { state: "ended" })], pulls: pullsByItem([{ item: 4, status: "draft" }, { item: null, status: "open" }]) }, | |
| 105 | + | { claims: [claim(2, "cy")], sessions: [live(3, { pull: 9, pullStatus: "open" }), live(1, { state: "ended" })], pulls: pullsByItem([{ repo: "acme/web", number: 20, item: 4, status: "draft" }, { repo: "acme/web", number: 21, item: null, status: "open" }]) }, | |
| 99 | 106 | NOW, | |
| 100 | 107 | ); | |
| 101 | 108 | assert.deepEqual(board.backlog.map((card) => card.item.number), [1]); | |
| ⋯ | |||
| 124 | 131 | { state: "closed", repos: [{ path: "acme/old" }] }, | |
| 125 | 132 | // A repository deleted before work items kept no path. | |
| 126 | 133 | { state: "open", repos: [{ path: "" }] }, | |
| 134 | + | // One deleted and not yet purged keeps its path, and has nothing to read. | |
| 135 | + | { state: "open", repos: [{ path: "acme/hello", deleted: true }] }, | |
| 127 | 136 | ]; | |
| 128 | 137 | assert.deepEqual(pullRepos(items), ["acme/web", "acme/api", "acme/repo-21"]); | |
| 129 | 138 | // However many there are: none is left out past a limit. | |
| 130 | 139 | const many = Array.from({ length: 30 }, (_, index) => ({ state: "open", repos: [{ path: `acme/r${index}` }] })); | |
| 131 | 140 | assert.equal(pullRepos(many).length, 30); | |
| 132 | 141 | }); | |
| 142 | + | ||
| 143 | + | test("closed as not planned, a duplicate among them, is not Done: it did not ship", () => { | |
| 144 | + | const closed = { state: "closed" as const, closedAt: new Date(NOW - DAY).toISOString() }; | |
| 145 | + | assert.equal(stageOf(item(1, { ...closed, reason: "completed" }), {}, NOW), "done"); | |
| 146 | + | assert.equal(stageOf(item(2, { ...closed, reason: "not_planned" }), {}, NOW), null); | |
| 147 | + | const board = boardOf([item(1, { ...closed, reason: "completed" }), item(2, { ...closed, reason: "not_planned" })], {}, NOW); | |
| 148 | + | assert.deepEqual(board.done.map((card) => card.item.number), [1]); | |
| 149 | + | }); | |
| 150 | + | ||
| 151 | + | test("a merged pull request keeps an open item on the board: Landing with a target to land, else In progress", () => { | |
| 152 | + | const target = [repo("acme/web", "origin"), repo("acme/api", "target")]; | |
| 153 | + | // It closes the item and merged, but not in the target: still to land there. | |
| 154 | + | assert.equal(stageOf(item(1, { repos: target }), { pulls: pr("merged") }, NOW), "landing"); | |
| 155 | + | // Only part of it merged: more work to come. | |
| 156 | + | assert.equal(stageOf(item(2, { repos: target }), { pulls: pr("part:merged") }, NOW), "progress"); | |
| 157 | + | // No target left to land, still open (merged into another branch, or reopened). | |
| 158 | + | assert.equal(stageOf(item(3), { pulls: pr("merged") }, NOW), "progress"); | |
| 159 | + | // An open pull request beside a merged part: In review. | |
| 160 | + | assert.equal(stageOf(item(4), { pulls: pr("part:merged", "open") }, NOW), "review"); | |
| 161 | + | // Nothing merged or open: where its claims put it. | |
| 162 | + | assert.equal(stageOf(item(5, { repos: target }), { pulls: pr("closed") }, NOW), "backlog"); | |
| 163 | + | }); | |
| 164 | + | ||
| 165 | + | test("a pull request counts for its item, the items it closes and those it is part of, once each", () => { | |
| 166 | + | const by = pullsByItem([ | |
| 167 | + | { repo: "acme/web", number: 158, item: 7, status: "merged", closes: [7, 8], parts: [9, 8] }, | |
| 168 | + | { repo: "Acme/Web", number: 158, item: 7, status: "merged", closes: [7] }, | |
| 169 | + | { repo: "acme/api", number: 3, item: null, status: "open", parts: [9] }, | |
| 170 | + | ]); | |
| 171 | + | assert.deepEqual(by[7], [{ key: "acme/web#158", status: "merged", part: false }]); | |
| 172 | + | assert.deepEqual(by[8], [{ key: "acme/web#158", status: "merged", part: false }]); | |
| 173 | + | assert.deepEqual(by[9], [ | |
| 174 | + | { key: "acme/web#158", status: "merged", part: true }, | |
| 175 | + | { key: "acme/api#3", status: "open", part: true }, | |
| 176 | + | ]); | |
| 177 | + | }); | |
| 178 | + | ||
| 179 | + | test("a card says how many of its pull requests merged, counting a live session's once", () => { | |
| 180 | + | const items = [item(1), item(2, { state: "closed", closedAt: new Date(NOW).toISOString() })]; | |
| 181 | + | const pulls = pullsByItem([ | |
| 182 | + | { repo: "acme/web", number: 30, item: 1, status: "merged" }, | |
| 183 | + | { repo: "acme/web", number: 31, item: null, status: "merged", parts: [1] }, | |
| 184 | + | { repo: "acme/web", number: 32, item: 2, status: "merged" }, | |
| 185 | + | ]); | |
| 186 | + | const board = boardOf(items, { sessions: [live(1, { pull: 30, pullStatus: "merged" })], pulls }, NOW); | |
| 187 | + | assert.equal(board.progress[0]!.merged, 2); | |
| 188 | + | // A closed one's are not counted: it is Done. | |
| 189 | + | assert.equal(board.done[0]!.merged, 0); | |
| 190 | + | }); | |
| 191 | + | ||
| 192 | + | test("every page is read, each below the last one's lowest number, up to a ceiling the board then names", async () => { | |
| 193 | + | const numbers = Array.from({ length: 250 }, (_, index) => ({ number: 250 - index })); | |
| 194 | + | const asked: (number | undefined)[] = []; | |
| 195 | + | const read = async (before: number | undefined) => { | |
| 196 | + | asked.push(before); | |
| 197 | + | return numbers.filter((row) => before == null || row.number < before).slice(0, 100); | |
| 198 | + | }; | |
| 199 | + | const all = await readPages(read, 100, 10); | |
| 200 | + | assert.equal(all.rows.length, 250); | |
| 201 | + | assert.equal(all.capped, false); | |
| 202 | + | assert.deepEqual(asked, [undefined, 151, 51]); | |
| 203 | + | const some = await readPages(read, 100, 2); | |
| 204 | + | assert.equal(some.rows.length, 200); | |
| 205 | + | assert.equal(some.capped, true); | |
| 206 | + | // An exact page then nothing: one more read says it is the end. | |
| 207 | + | const exact = await readPages(async (before) => (before == null ? numbers.slice(0, 100) : []), 100, 10); | |
| 208 | + | assert.deepEqual([exact.rows.length, exact.capped], [100, false]); | |
| 209 | + | }); | |
| 210 | + | ||
| 211 | + | test("what the board read only in part, it says", () => { | |
| 212 | + | assert.deepEqual(cappedNotes({ items: null, done: null, pulls: [] }), []); | |
| 213 | + | assert.deepEqual(cappedNotes({ items: 1000, done: 100, pulls: ["acme/web"] }), [ | |
| 214 | + | "Showing the newest 1,000 open work items. Older ones are on the Work list.", | |
| 215 | + | "Done shows the 100 most recently closed.", | |
| 216 | + | "Only the newest pull requests in acme/web were read, so some work items may stand a column early.", | |
| 217 | + | ]); | |
| 218 | + | assert.match(cappedNotes({ items: null, done: null, pulls: ["acme/web", "acme/api"] })[0]!, /in 2 repositories/); | |
| 219 | + | }); | |
| 12 | 12 | ||
| 13 | 13 | export const STAGE_INFO: Record<Stage, { label: string; hint: string }> = { | |
| 14 | 14 | backlog: { label: "Backlog", hint: "Open, and nobody is on it" }, | |
| 15 | − | progress: { label: "In progress", hint: "Claimed or assigned, a live session on it, or a draft pull request" }, | |
| 15 | + | progress: { label: "In progress", hint: "Claimed or assigned, a live session on it, a draft pull request, or a pull request merged that did not close it" }, | |
| 16 | 16 | review: { label: "In review", hint: "A pull request for it is open" }, | |
| 17 | − | landing: { label: "Landing", hint: "Landed in some of its repositories, still missing in others" }, | |
| 18 | − | done: { label: "Done", hint: "Closed in the last 14 days" }, | |
| 17 | + | landing: { label: "Landing", hint: "Landed or merged somewhere, and a target repository is still missing it" }, | |
| 18 | + | done: { label: "Done", hint: "Closed as completed in the last 14 days" }, | |
| 19 | 19 | }; | |
| 20 | 20 | ||
| 21 | − | /** How long a closed work item stays in Done. */ | |
| 21 | + | /** How long a closed work item stays in Done, and how far back the board reads merged pull requests. */ | |
| 22 | 22 | export const DONE_DAYS = 14; | |
| 23 | 23 | ||
| 24 | − | /** What is known of a work item's pull requests: their statuses. */ | |
| 25 | − | export type ItemPulls = Record<number, PullStatus[]>; | |
| 24 | + | /** | |
| 25 | + | * One of a work item's pull requests, as the board counts it: one for the | |
| 26 | + | * item (`item`), one that closes it (`closes`), or one that is only part | |
| 27 | + | * of it (`parts`), which merging does not close. | |
| 28 | + | */ | |
| 29 | + | export type ItemPull = { | |
| 30 | + | /** `owner/name#number`, any case, so one counted twice is counted once. */ | |
| 31 | + | key: string; | |
| 32 | + | status: PullStatus; | |
| 33 | + | /** It is only part of the work item: merging it lands nothing. */ | |
| 34 | + | part: boolean; | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | /** What is known of each work item's pull requests, by the item's number. */ | |
| 38 | + | export type ItemPulls = Record<number, ItemPull[]>; | |
| 26 | 39 | ||
| 27 | 40 | /** Who or what is on a work item, for its card. */ | |
| 28 | 41 | export type OnIt = { | |
| ⋯ | |||
| 33 | 46 | }; | |
| 34 | 47 | ||
| 35 | 48 | /** | |
| 36 | − | * The stage a work item is in, the first that holds: closed recently is | |
| 37 | − | * Done; landed in some target repositories and not others is Landing; an | |
| 38 | − | * open pull request is In review; claimed, assigned, a live session or a | |
| 39 | − | * draft is In progress; anything else open is Backlog. Null for one closed | |
| 40 | − | * longer ago than [`DONE_DAYS`], which the board leaves out. | |
| 49 | + | * The stage a work item is in, the first that holds: | |
| 50 | + | * | |
| 51 | + | * - **Done**: closed as completed in the last [`DONE_DAYS`]. Closed as not | |
| 52 | + | * planned (which duplicates are) did not ship, and is left out, as is | |
| 53 | + | * one closed longer ago: null. | |
| 54 | + | * - **Landing**: landed in some target repositories and not others; or a | |
| 55 | + | * pull request that closes it merged and a target is still missing it | |
| 56 | + | * (it merged somewhere that is not a target). | |
| 57 | + | * - **In review**: a pull request for it is open. | |
| 58 | + | * - **In progress**: claimed, assigned, a live session, a draft, or a pull | |
| 59 | + | * request merged without closing it (part of it, or into a branch that | |
| 60 | + | * is not the default). | |
| 61 | + | * - **Backlog**: anything else open. | |
| 41 | 62 | */ | |
| 42 | − | export function stageOf(item: WorkItem, facts: { pulls?: PullStatus[]; claimed?: boolean; live?: boolean }, now: number): Stage | null { | |
| 63 | + | export function stageOf(item: WorkItem, facts: { pulls?: ItemPull[]; claimed?: boolean; live?: boolean }, now: number): Stage | null { | |
| 43 | 64 | if (item.state === "closed") { | |
| 65 | + | if (item.reason === "not_planned") return null; | |
| 44 | 66 | const closed = item.closedAt ? Date.parse(item.closedAt) : 0; | |
| 45 | 67 | return now - closed <= DONE_DAYS * 86_400_000 ? "done" : null; | |
| 46 | 68 | } | |
| 47 | 69 | const targets = item.repos.filter((repo) => repo.role === "target"); | |
| 48 | − | if (targets.some((repo) => repo.landed) && targets.some((repo) => !repo.landed)) return "landing"; | |
| 70 | + | const missing = targets.some((repo) => !repo.landed); | |
| 71 | + | if (missing && targets.some((repo) => repo.landed)) return "landing"; | |
| 49 | 72 | const pulls = facts.pulls ?? []; | |
| 50 | − | if (pulls.includes("open")) return "review"; | |
| 51 | − | if (facts.claimed || facts.live || item.assignees.length > 0 || pulls.includes("draft")) return "progress"; | |
| 73 | + | if (missing && pulls.some((pull) => pull.status === "merged" && !pull.part)) return "landing"; | |
| 74 | + | if (pulls.some((pull) => pull.status === "open")) return "review"; | |
| 75 | + | const merged = pulls.some((pull) => pull.status === "merged"); | |
| 76 | + | if (facts.claimed || facts.live || item.assignees.length > 0 || merged || pulls.some((pull) => pull.status === "draft")) return "progress"; | |
| 52 | 77 | return "backlog"; | |
| 53 | 78 | } | |
| 54 | 79 | ||
| 55 | − | export type BoardCard = { item: WorkItem; stage: Stage; on: OnIt }; | |
| 80 | + | export type BoardCard = { | |
| 81 | + | item: WorkItem; | |
| 82 | + | stage: Stage; | |
| 83 | + | on: OnIt; | |
| 84 | + | /** How many of an open work item's pull requests merged in the last [`DONE_DAYS`] (none for a closed one). */ | |
| 85 | + | merged: number; | |
| 86 | + | }; | |
| 56 | 87 | ||
| 57 | 88 | export type Board = Record<Stage, BoardCard[]>; | |
| 58 | 89 | ||
| ⋯ | |||
| 73 | 104 | seen.add(item.number); | |
| 74 | 105 | const claimed = claims.filter((claim) => claim.item === item.number && claim.state === "active"); | |
| 75 | 106 | const live = sessions.filter((session) => session.item === item.number && session.state !== "ended"); | |
| 76 | − | // A live session's pull request counts as one of the item's. | |
| 77 | − | const known = [...(pulls[item.number] ?? []), ...live.flatMap((session) => (session.pullStatus ? [session.pullStatus] : []))]; | |
| 78 | − | const stage = stageOf(item, { pulls: known, claimed: claimed.length > 0, live: live.length > 0 }, now); | |
| 107 | + | // A live session's pull request counts as one of the item's, once. | |
| 108 | + | const known = new Map((pulls[item.number] ?? []).map((pull) => [pull.key, pull])); | |
| 109 | + | for (const session of live) { | |
| 110 | + | if (session.pull == null || !session.pullStatus) continue; | |
| 111 | + | const key = `${session.repo}#${session.pull}`.toLowerCase(); | |
| 112 | + | if (!known.has(key)) known.set(key, { key, status: session.pullStatus, part: false }); | |
| 113 | + | } | |
| 114 | + | const stage = stageOf(item, { pulls: [...known.values()], claimed: claimed.length > 0, live: live.length > 0 }, now); | |
| 79 | 115 | if (!stage) continue; | |
| 80 | 116 | const people = [...item.assignees, ...claimed.map((claim) => claim.actor.username), ...live.flatMap((session) => (session.actor ? [session.actor] : []))]; | |
| 81 | − | board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live } }); | |
| 117 | + | const merged = item.state === "open" ? [...known.values()].filter((pull) => pull.status === "merged").length : 0; | |
| 118 | + | board[stage].push({ item, stage, on: { people: [...new Set(people)], sessions: live }, merged }); | |
| 82 | 119 | } | |
| 83 | 120 | for (const stage of STAGES) { | |
| 84 | 121 | const at = (card: BoardCard) => Date.parse(stage === "done" ? (card.item.closedAt ?? card.item.updatedAt) : card.item.updatedAt); | |
| ⋯ | |||
| 88 | 125 | } | |
| 89 | 126 | ||
| 90 | 127 | /** | |
| 91 | − | * The repositories whose open pull requests the board reads: every one an | |
| 92 | − | * open work item is linked to, each once, so an item's pull request moves | |
| 93 | − | * it wherever it was opened. A repository deleted before work items, with | |
| 94 | − | * no path kept, has none to read. | |
| 128 | + | * The repositories whose pull requests the board reads: every one an open | |
| 129 | + | * work item is linked to, each once, so an item's pull request moves it | |
| 130 | + | * wherever it was opened. A deleted one (which can still be restored) has | |
| 131 | + | * none anyone can read, nor one purged with no path kept. | |
| 95 | 132 | */ | |
| 96 | − | export function pullRepos(items: readonly { state: string; repos: readonly { path: string }[] }[]): string[] { | |
| 133 | + | export function pullRepos(items: readonly { state: string; repos: readonly { path: string; deleted?: boolean }[] }[]): string[] { | |
| 97 | 134 | const seen = new Map<string, string>(); | |
| 98 | 135 | for (const item of items) { | |
| 99 | 136 | if (item.state !== "open") continue; | |
| 100 | − | for (const repo of item.repos) if (repo.path && !seen.has(repo.path.toLowerCase())) seen.set(repo.path.toLowerCase(), repo.path); | |
| 137 | + | for (const repo of item.repos) { | |
| 138 | + | if (!repo.path || repo.deleted || seen.has(repo.path.toLowerCase())) continue; | |
| 139 | + | seen.set(repo.path.toLowerCase(), repo.path); | |
| 140 | + | } | |
| 101 | 141 | } | |
| 102 | 142 | return [...seen.values()]; | |
| 103 | 143 | } | |
| 104 | 144 | ||
| 105 | − | /** Each work item's pull requests' statuses, from pull requests that name their item. */ | |
| 106 | − | export function pullsByItem(pulls: { item: number | null; status: PullStatus }[]): ItemPulls { | |
| 145 | + | /** A pull request as the board reads it, with the repository it is in. */ | |
| 146 | + | export type ReadPull = { repo: string; number: number; item: number | null; status: PullStatus; closes?: number[]; parts?: number[] }; | |
| 147 | + | ||
| 148 | + | /** | |
| 149 | + | * Each work item's pull requests: one counts for every work item it is | |
| 150 | + | * for (`item`), closes (`closes`) or is part of (`parts`), and only as a | |
| 151 | + | * part when it does not also close it. | |
| 152 | + | */ | |
| 153 | + | export function pullsByItem(pulls: readonly ReadPull[]): ItemPulls { | |
| 107 | 154 | const by: ItemPulls = {}; | |
| 155 | + | const add = (number: number, pull: ItemPull) => { | |
| 156 | + | const known = (by[number] ??= []); | |
| 157 | + | if (!known.some((one) => one.key === pull.key)) known.push(pull); | |
| 158 | + | }; | |
| 108 | 159 | for (const pull of pulls) { | |
| 109 | − | if (pull.item == null) continue; | |
| 110 | − | (by[pull.item] ??= []).push(pull.status); | |
| 160 | + | const key = `${pull.repo}#${pull.number}`.toLowerCase(); | |
| 161 | + | const closing = new Set([...(pull.item == null ? [] : [pull.item]), ...(pull.closes ?? [])]); | |
| 162 | + | for (const number of closing) add(number, { key, status: pull.status, part: false }); | |
| 163 | + | for (const number of pull.parts ?? []) if (!closing.has(number)) add(number, { key, status: pull.status, part: true }); | |
| 111 | 164 | } | |
| 112 | 165 | return by; | |
| 113 | 166 | } | |
| 114 | 167 | ||
| 168 | + | /** A list read page after page: its rows, and whether there may be more than were read. */ | |
| 169 | + | export type Paged<T> = { rows: T[]; capped: boolean }; | |
| 170 | + | ||
| 171 | + | /** | |
| 172 | + | * Every page of a list that comes newest first by number: `read` gives the | |
| 173 | + | * page below `before` (none for the first), and a page shorter than `size` | |
| 174 | + | * is the last. At most `pages` are read; `capped` says there may be more. | |
| 175 | + | */ | |
| 176 | + | export async function readPages<T extends { number: number }>( | |
| 177 | + | read: (before: number | undefined) => Promise<T[]>, | |
| 178 | + | size: number, | |
| 179 | + | pages: number, | |
| 180 | + | ): Promise<Paged<T>> { | |
| 181 | + | const rows: T[] = []; | |
| 182 | + | let before: number | undefined; | |
| 183 | + | for (let page = 0; page < pages; page++) { | |
| 184 | + | const found = await read(before); | |
| 185 | + | rows.push(...found); | |
| 186 | + | if (found.length < size) return { rows, capped: false }; | |
| 187 | + | before = Math.min(...found.map((row) => row.number)); | |
| 188 | + | } | |
| 189 | + | return { rows, capped: true }; | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /** Pages of open work items the board reads at most: 1,000 at 100 a page. */ | |
| 193 | + | export const ITEM_PAGES = 10; | |
| 194 | + | /** Pages of each repository's open, and of its recently merged, pull requests the board reads at most. */ | |
| 195 | + | export const PULL_PAGES = 3; | |
| 196 | + | ||
| 197 | + | /** | |
| 198 | + | * What the board read only in part, in sentences: the open work items | |
| 199 | + | * when there were more than it reads (`items`, how many it showed), Done | |
| 200 | + | * when it holds a full page (`done`), and the repositories with more pull | |
| 201 | + | * requests than it reads (`pulls`). | |
| 202 | + | */ | |
| 203 | + | export function cappedNotes({ items, done, pulls }: { items: number | null; done: number | null; pulls: string[] }): string[] { | |
| 204 | + | const notes: string[] = []; | |
| 205 | + | if (items != null) notes.push(`Showing the newest ${items.toLocaleString("en-US")} open work items. Older ones are on the Work list.`); | |
| 206 | + | if (done != null) notes.push(`Done shows the ${done} most recently closed.`); | |
| 207 | + | if (pulls.length > 0) { | |
| 208 | + | const where = pulls.length === 1 ? pulls[0] : `${pulls.length} repositories`; | |
| 209 | + | notes.push(`Only the newest pull requests in ${where} were read, so some work items may stand a column early.`); | |
| 210 | + | } | |
| 211 | + | return notes; | |
| 212 | + | } | |
| 213 | + | ||
| 115 | 214 | /** The stage a phone shows, from the address: a known one, else the first with anything in it, else Backlog. */ | |
| 116 | 215 | export function shownStage(asked: string | null, board: Board): Stage { | |
| 117 | 216 | if (asked && (STAGES as string[]).includes(asked)) return asked as Stage; | |
| 3 | 3 | ||
| 4 | 4 | import type { WorkSession, WorkspaceSession } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { narrowed, peopleIn, repoName, runsIn, sessionCost, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 6 | + | import { narrowed, onItemSessionTitle, peopleIn, repoName, runsIn, sessionCost, sessionHref, sessionsFilters, sessionsQuery, splitLive, workspaceSessionTitle } from "./sessions.ts"; | |
| 7 | 7 | ||
| 8 | 8 | function listed(id: string, more: Partial<WorkspaceSession> = {}): WorkspaceSession { | |
| 9 | 9 | return { | |
| ⋯ | |||
| 54 | 54 | assert.equal(runsIn(listed("h", { kind: "recorded", harness: null, agent: "cursor" })), "cursor, recorded"); | |
| 55 | 55 | }); | |
| 56 | 56 | ||
| 57 | + | test("on a work item's card, a session with no title says what it is on, never a bare pull request number", () => { | |
| 58 | + | assert.equal(onItemSessionTitle(listed("a", { session: own({ title: "Fix login" }) })), "Fix login"); | |
| 59 | + | assert.equal(onItemSessionTitle(listed("d", { kind: "recorded", pull: 7, pullTitle: "Retry hooks" })), "Retry hooks"); | |
| 60 | + | assert.equal(onItemSessionTitle(listed("e", { kind: "agent", pull: 158 })), "On pull request #158"); | |
| 61 | + | assert.equal(onItemSessionTitle(listed("f", { kind: "agent", pull: null })), "Working on it"); | |
| 62 | + | }); | |
| 63 | + | ||
| 57 | 64 | test("Sessions keeps its filters in the address and drops what it does not know", () => { | |
| 58 | 65 | const filters = sessionsFilters(new URLSearchParams("repo=web&person=@bo&harness=codex&kind=bogus&state=recent")); | |
| 59 | 66 | assert.deepEqual(filters, { state: "recent", repo: "web", person: "bo", harness: "codex", kind: null, item: null }); | |
| 26 | 26 | return session.title?.trim() || session.pullTitle?.trim() || (session.pull != null ? `Pull request #${session.pull}` : "Untitled session"); | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | /** | |
| 30 | + | * What a session is called on a work item's card: its own title, else | |
| 31 | + | * what it is doing there. Never a bare "Pull request #N", whose number | |
| 32 | + | * would read as the work item's. | |
| 33 | + | */ | |
| 34 | + | export function onItemSessionTitle(session: WorkspaceSession): string { | |
| 35 | + | if (session.session || session.title?.trim() || session.pullTitle?.trim()) return workspaceSessionTitle(session); | |
| 36 | + | return session.pull != null ? `On pull request #${session.pull}` : "Working on it"; | |
| 37 | + | } | |
| 38 | + | ||
| 29 | 39 | /** What runs it, in a few words: the harness, g1t's agent, or the agent that recorded it. */ | |
| 30 | 40 | export function runsIn(session: Pick<WorkspaceSession, "kind" | "harness" | "agent">): string { | |
| 31 | 41 | if (session.kind === "agent") return "g1t's agent"; |
| 1 | − | import { Bot, Columns3, Plus } from "lucide-react"; | |
| 1 | + | import { Bot, Columns3, GitMerge, Plus } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import type { Pull } from "@g1t/contracts"; | |
| 4 | + | import { LIST_PAGE } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/board"; | |
| 7 | 7 | import { HarnessMark, StateDot } from "../../../components/sessions"; | |
| ⋯ | |||
| 11 | 11 | import { Hint } from "../../../components/ui/hint"; | |
| 12 | 12 | import { RepoChips } from "../../../components/work-items"; | |
| 13 | 13 | import { ItemIcon } from "../../../components/work-icons"; | |
| 14 | − | import { type Board, type BoardCard, STAGES, STAGE_INFO, type Stage, boardOf, pullRepos, pullsByItem, shownStage } from "../../../lib/board"; | |
| 14 | + | import { | |
| 15 | + | type Board, | |
| 16 | + | type BoardCard, | |
| 17 | + | DONE_DAYS, | |
| 18 | + | ITEM_PAGES, | |
| 19 | + | PULL_PAGES, | |
| 20 | + | type ReadPull, | |
| 21 | + | STAGES, | |
| 22 | + | STAGE_INFO, | |
| 23 | + | type Stage, | |
| 24 | + | boardOf, | |
| 25 | + | cappedNotes, | |
| 26 | + | pullRepos, | |
| 27 | + | pullsByItem, | |
| 28 | + | readPages, | |
| 29 | + | shownStage, | |
| 30 | + | } from "../../../lib/board"; | |
| 15 | 31 | import { cn } from "../../../lib/cn"; | |
| 16 | 32 | import { page } from "../../../lib/meta"; | |
| 17 | 33 | import { isFailed, part } from "../../../lib/partial"; | |
| ⋯ | |||
| 19 | 35 | import { workPages } from "../../../lib/repo-live"; | |
| 20 | 36 | import { work } from "../../../lib/services.server"; | |
| 21 | 37 | import { getViewer, roleIn, unwrap } from "../../../lib/session.server"; | |
| 22 | − | import { runsIn, sessionHref, workspaceSessionTitle } from "../../../lib/sessions"; | |
| 38 | + | import { onItemSessionTitle, runsIn, sessionHref } from "../../../lib/sessions"; | |
| 23 | 39 | import { useWorkspaceLive } from "../../../lib/use-repo-live"; | |
| 24 | 40 | import { itemPath, newItemPath, repoFilter, shortPath, workPath } from "../../../lib/work-items"; | |
| 25 | 41 | ||
| ⋯ | |||
| 35 | 51 | const repoName = query.get("repo")?.trim() || null; | |
| 36 | 52 | const repo = repoName ? repoFilter(workspace, repoName) : null; | |
| 37 | 53 | const member = roleIn(viewer, workspace) != null; | |
| 38 | − | // The work items are what the board is; what says where each stands, a part each. | |
| 54 | + | const now = Date.now(); | |
| 55 | + | const since = new Date(now - DONE_DAYS * 86_400_000).toISOString(); | |
| 56 | + | // The open work items are what the board is, every page of them; what says where each stands, a part each. | |
| 39 | 57 | const [open, closed, claims, live] = await Promise.all([ | |
| 40 | − | work.listWorkItems(workspace, viewer, { state: "open", repo: repo ?? undefined }), | |
| 41 | − | part(work.listWorkItems(workspace, viewer, { state: "closed", repo: repo ?? undefined }), "Done"), | |
| 58 | + | readPages((before) => work.listWorkItems(workspace, viewer, { state: "open", repo: repo ?? undefined, before }).then(unwrap), LIST_PAGE, ITEM_PAGES), | |
| 59 | + | part(work.listWorkItems(workspace, viewer, { state: "closed", repo: repo ?? undefined, closedSince: since }), "Done"), | |
| 42 | 60 | part(work.listClaims(workspace, viewer, { state: "active", repo: repo ?? undefined }), "Claims"), | |
| 43 | 61 | part(work.listWorkspaceSessions(workspace, viewer, { shown: "live", sessionsOnly: true, repo: repoName ?? undefined, limit: 200 }), "Live sessions"), | |
| 44 | 62 | ]); | |
| 45 | 63 | const sessions = isFailed(live) || !live.ok ? [] : live.value.sessions; | |
| 46 | − | // The open pull requests that name their work items, in every repository an open item is linked to. | |
| 47 | − | const openItems = unwrap(open); | |
| 48 | − | const repos = pullRepos(openItems); | |
| 49 | − | const pulls = await Promise.all( | |
| 50 | − | repos.map((path) => { | |
| 51 | − | const [namespace = "", name = ""] = path.split("/"); | |
| 52 | − | return work.listPulls({ namespace, name }, viewer, "open").catch(() => null); | |
| 53 | − | }), | |
| 54 | − | ); | |
| 55 | − | const openPulls: Pull[] = pulls.flatMap((found) => (found?.ok ? found.value : [])); | |
| 56 | − | // A repository whose pull requests could not be read: its items may stand a column early, and the board says so. | |
| 57 | − | const unread = repos.filter((_, index) => !pulls[index]?.ok); | |
| 58 | − | const now = Date.now(); | |
| 59 | − | const board = boardOf( | |
| 60 | − | [...openItems, ...(isFailed(closed) || !closed.ok ? [] : closed.value)], | |
| 61 | − | { claims: isFailed(claims) || !claims.ok ? [] : claims.value, sessions, pulls: pullsByItem(openPulls) }, | |
| 62 | − | now, | |
| 63 | − | ); | |
| 64 | + | // The open and recently merged pull requests in every repository an open item is linked to. | |
| 65 | + | const repos = pullRepos(open.rows); | |
| 66 | + | const read = (path: string, state: "open" | "closed") => { | |
| 67 | + | const [namespace = "", name = ""] = path.split("/"); | |
| 68 | + | // Not found or refused is an answer: none the viewer can see there. | |
| 69 | + | const page = (before: number | undefined) => | |
| 70 | + | work | |
| 71 | + | .listPulls({ namespace, name }, viewer, state, { before, mergedSince: state === "closed" ? since : undefined }) | |
| 72 | + | .then((found) => (found.ok ? found.value : [])); | |
| 73 | + | return part(readPages(page, LIST_PAGE, PULL_PAGES), `Pull requests in ${path}`); | |
| 74 | + | }; | |
| 75 | + | const pulls = await Promise.all(repos.map((path) => Promise.all([read(path, "open"), read(path, "closed")]))); | |
| 76 | + | const readPulls: ReadPull[] = []; | |
| 77 | + | const unread: string[] = []; | |
| 78 | + | const capped: string[] = []; | |
| 79 | + | repos.forEach((path, index) => { | |
| 80 | + | const both = pulls[index]!; | |
| 81 | + | if (both.some(isFailed)) unread.push(path); | |
| 82 | + | for (const found of both) { | |
| 83 | + | if (isFailed(found)) continue; | |
| 84 | + | if (found.capped) capped.push(path); | |
| 85 | + | readPulls.push(...found.rows.map((pull) => ({ ...pull, repo: path }))); | |
| 86 | + | } | |
| 87 | + | }); | |
| 88 | + | const done = isFailed(closed) || !closed.ok ? [] : closed.value; | |
| 89 | + | const board = boardOf([...open.rows, ...done], { claims: isFailed(claims) || !claims.ok ? [] : claims.value, sessions, pulls: pullsByItem(readPulls) }, now); | |
| 64 | 90 | return { | |
| 65 | 91 | workspace, | |
| 66 | 92 | member, | |
| ⋯ | |||
| 68 | 94 | repo: repoName, | |
| 69 | 95 | board, | |
| 70 | 96 | stage: shownStage(query.get("stage"), board), | |
| 97 | + | // A part that could not be read: its items may be missing, or stand a column early, and the board says so. | |
| 71 | 98 | unavailable: [ | |
| 72 | 99 | ...[closed, claims, live].filter(isFailed).map((failed) => failed.what), | |
| 73 | 100 | ...(unread.length === 0 ? [] : [unread.length === 1 ? `Pull requests in ${unread[0]}` : `Pull requests in ${unread.length} repositories`]), | |
| 74 | 101 | ], | |
| 102 | + | // What was read only in part, said plainly rather than left out silently. | |
| 103 | + | capped: cappedNotes({ items: open.capped ? open.rows.length : null, done: done.length >= LIST_PAGE ? LIST_PAGE : null, pulls: [...new Set(capped)] }), | |
| 75 | 104 | live: sessions.length, | |
| 76 | 105 | }; | |
| 77 | 106 | } | |
| ⋯ | |||
| 93 | 122 | </span> | |
| 94 | 123 | </Hint> | |
| 95 | 124 | )} | |
| 125 | + | {people.length > 3 && <span className="text-[0.6875rem] text-faint">+{people.length - 3}</span>} | |
| 96 | 126 | {sessions.slice(0, 2).map((session) => { | |
| 97 | 127 | const to = sessionHref(workspace, session); | |
| 98 | 128 | const label = ( | |
| ⋯ | |||
| 103 | 133 | </span> | |
| 104 | 134 | ); | |
| 105 | 135 | return ( | |
| 106 | − | <Hint key={session.id} label={`${workspaceSessionTitle(session)} · ${runsIn(session)}`}> | |
| 136 | + | <Hint key={session.id} label={`${onItemSessionTitle(session)} · ${runsIn(session)}`}> | |
| 107 | 137 | {to ? ( | |
| 108 | 138 | <Link to={to} className="min-w-0 hover:text-fg"> | |
| 109 | 139 | {label} | |
| ⋯ | |||
| 136 | 166 | {item.title} <span className="font-normal text-faint">#{item.number}</span> | |
| 137 | 167 | </span> | |
| 138 | 168 | </Link> | |
| 139 | − | <RepoChips repos={item.repos} workspace={workspace} /> | |
| 169 | + | {(item.repos.length > 0 || card.merged > 0) && ( | |
| 170 | + | <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"> | |
| 171 | + | <RepoChips repos={item.repos} workspace={workspace} /> | |
| 172 | + | {card.merged > 0 && ( | |
| 173 | + | <Hint label={`${card.merged === 1 ? "A pull request" : `${card.merged} pull requests`} for it merged in the last ${DONE_DAYS} days`}> | |
| 174 | + | <span className="inline-flex shrink-0 items-center gap-1 text-[0.6875rem] text-merged"> | |
| 175 | + | <GitMerge size={11} /> | |
| 176 | + | {card.merged} merged | |
| 177 | + | </span> | |
| 178 | + | </Hint> | |
| 179 | + | )} | |
| 180 | + | </div> | |
| 181 | + | )} | |
| 140 | 182 | <OnIt card={card} workspace={workspace} /> | |
| 141 | 183 | </Card> | |
| 142 | 184 | </li> | |
| ⋯ | |||
| 172 | 214 | * stages that wraps rather than scrolling sideways. | |
| 173 | 215 | */ | |
| 174 | 216 | export default function BoardPage({ loaderData }: Route.ComponentProps) { | |
| 175 | − | const { workspace, board, stage, repo, member, signedIn, unavailable, live } = loaderData; | |
| 217 | + | const { workspace, board, stage, repo, member, signedIn, unavailable, capped, live } = loaderData; | |
| 176 | 218 | const total = STAGES.reduce((sum, one) => sum + board[one].length, 0); | |
| 177 | 219 | const query = (more: Record<string, string>) => `?${new URLSearchParams({ ...(repo ? { repo } : {}), ...more }).toString()}`; | |
| 178 | 220 | useWorkspaceLive({ workspace, member, match: workPages.list() }); | |
| ⋯ | |||
| 197 | 239 | )} | |
| 198 | 240 | </div> | |
| 199 | 241 | {unavailable.length > 0 && <p className="mt-3 text-xs text-warn">{unavailable.join(", ")} could not be loaded just now, so this board is partial: some work items may be missing, or stand a column early.</p>} | |
| 242 | + | {capped.length > 0 && <p className="mt-2 text-xs text-muted">{capped.join(" ")}</p>} | |
| 200 | 243 | ||
| 201 | 244 | {total === 0 ? ( | |
| 202 | 245 | <div className="mt-5"> | |
| 341 | 341 | return ( | |
| 342 | 342 | <li className="px-3 py-3 sm:px-4"> | |
| 343 | 343 | <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 ? ( | |
| 345 | 352 | <Link to={`/${repo.path}`} className="min-w-0 truncate font-mono text-sm font-medium hover:text-accent"> | |
| 346 | 353 | {repo.path} | |
| 347 | 354 | </Link> | |
| ⋯ | |||
| 534 | 541 | const pullHref = pullHrefs(repoRows.map(({ repo, pulls: repoPulls }) => ({ path: repo.path, pulls: repoPulls }))); | |
| 535 | 542 | const failureBase = `/${originOf(item.repos)?.path || workspace}`; | |
| 536 | 543 | // 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; | |
| 538 | 547 | const linkError = actionData && "action" in actionData && actionData.action === "link" ? actionData.error : null; | |
| 539 | 548 | const sideError = actionData && "action" in actionData && ["blocked-by", "start-session", "react", "link", "open-pull"].includes(String(actionData.action)); | |
| 540 | 549 | ||
| ⋯ | |||
| 920 | 929 | <div className="mt-3"> | |
| 921 | 930 | <CopyLine text={reference} /> | |
| 922 | 931 | </div> | |
| 923 | − | {viewer && item.repos.length > 0 ? ( | |
| 932 | + | {viewer && openable.length > 0 ? ( | |
| 924 | 933 | <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4"> | |
| 925 | 934 | <input type="hidden" name="action" value="open-pull" /> | |
| 926 | 935 | <p className="text-xs text-muted">Or open a draft pull request yourself and get a fork to push to.</p> | |
| 927 | 936 | <SelectField | |
| 928 | 937 | name="repo" | |
| 929 | 938 | 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()})` }))} | |
| 932 | 941 | /> | |
| 933 | 942 | <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} /> | |
| 934 | 943 | <div className="*:w-full"> | |
Comments
Sign in to comment.