Public: anyone can read this; the crew can change it.
A change never says which tell it answers, so work whose tell is already closed still reads as open work #480
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/289-a-change-never-says-which-tell-it-answer → main 1 commit fork
5 files
changed +161
−2
src/web/studio/branch_tell.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** | |
| 2 | * Which tell a change answers. The crew names every branch for it — | |
| 3 | * `scout/13-after-new-app-land-on-a-page` answers tell #13 — so the number | |
| 4 | * is read back out of the head branch. A convention, not a contract: any | |
| 5 | * branch that does not follow it, or names a number this repository has no | |
| 6 | * tell for, answers no tell, and nothing here throws. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import type { Pull } from "../../core/pulls.ts"; | |
| 11 | import { html, type Html } from "../html.ts"; | |
| 12 | import { tellHref } from "./nouns.ts"; | |
| 13 | ||
| 14 | export interface AnsweredTell { | |
| 15 | readonly number: number; | |
| 16 | readonly closed: boolean; | |
| 17 | } | |
| 18 | ||
| 19 | /** `<crew>/<number>-<slug>` → the number; anything else → undefined. Only | |
| 20 | * the first digits after the first slash count, so digits in the slug never | |
| 21 | * matter, and a number with a leading zero or past safe range is no tell. */ | |
| 22 | export function tellNumberOf(branch: string): number | undefined { | |
| 23 | const m = /^[^/\s]+\/([1-9][0-9]{0,8})-[^\s]*$/.exec(branch); | |
| 24 | return m ? Number(m[1]) : undefined; | |
| 25 | } | |
| 26 | ||
| 27 | /** The tell each pull answers, keyed by pull number, in one grouped read for | |
| 28 | * the whole page. A pull from another repository's branch, or whose number | |
| 29 | * matches no tell here (a pull is not a tell), is left out. */ | |
| 30 | export function tellsAnswered( | |
| 31 | db: Database, | |
| 32 | repoId: string, | |
| 33 | pulls: readonly Pull[], | |
| 34 | ): Map<number, AnsweredTell> { | |
| 35 | const wanted = new Map<number, number>(); | |
| 36 | for (const pull of pulls) { | |
| 37 | if (pull.crossRepo || pull.originHeadRepo !== null) continue; | |
| 38 | const n = tellNumberOf(pull.headBranch); | |
| 39 | if (n !== undefined) wanted.set(pull.number, n); | |
| 40 | } | |
| 41 | const out = new Map<number, AnsweredTell>(); | |
| 42 | if (wanted.size === 0) return out; | |
| 43 | const numbers = [...new Set(wanted.values())]; | |
| 44 | const state = new Map<number, boolean>(); | |
| 45 | const CHUNK = 400; | |
| 46 | for (let i = 0; i < numbers.length; i += CHUNK) { | |
| 47 | const batch = numbers.slice(i, i + CHUNK); | |
| 48 | const rows = db | |
| 49 | .query<{ number: number; state: string }, (string | number)[]>( | |
| 50 | `SELECT number, state FROM issue | |
| 51 | WHERE repo_id = ?1 AND kind = 'issue' | |
| 52 | AND number IN (${batch.map((_, k) => `?${k + 2}`).join(",")})`, | |
| 53 | ) | |
| 54 | .all(repoId, ...batch); | |
| 55 | for (const r of rows) state.set(r.number, r.state === "closed"); | |
| 56 | } | |
| 57 | for (const [pullNumber, n] of wanted) { | |
| 58 | const closed = state.get(n); | |
| 59 | if (closed !== undefined) out.set(pullNumber, { number: n, closed }); | |
| 60 | } | |
| 61 | return out; | |
| 62 | } | |
| 63 | ||
| 64 | /** The line a change carries for its tell: a link in the studio's own noun, | |
| 65 | * and — when that tell is closed — what a person can act on. Never a verdict: | |
| 66 | * the change's word is judged elsewhere and is not touched by this. */ | |
| 67 | export function tellLine(base: string, t: AnsweredTell): Html { | |
| 68 | const link = html`<a href="${tellHref(base, t.number)}">tell #${t.number}</a>`; | |
| 69 | return t.closed | |
| 70 | ? html`for ${link} — its tell is already done` | |
| 71 | : html`for ${link}`; | |
| 72 | } |
src/web/studio/change.ts +6
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | decisionsFor, |
| 39 | 39 | type Decision, |
| 40 | 40 | } from "./asks.ts"; |
| 41 | import { tellLine, tellsAnswered } from "./branch_tell.ts"; | |
| 41 | 42 | import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts"; |
| 42 | 43 | |
| 43 | 44 | /** What a conflicted change's own page says: the base it no longer merges |
| @@ -167,6 +168,11 @@ export async function studioPull( | ||
| 167 | 168 | const checkLine = card( |
| 168 | 169 | html`<p class="studio-pull-check"> |
| 169 | 170 | ${wordBadge(word)} <span class="muted small">${note}</span> |
| 171 | ${when( | |
| 172 | tellsAnswered(db, repo.id, [pull]).get(pull.number), | |
| 173 | (t) => html`<span class="where-sep" aria-hidden="true">·</span | |
| 174 | >${tellLine(base, t)}`, | |
| 175 | )} | |
| 170 | 176 | ${when( |
| 171 | 177 | deploy, |
| 172 | 178 | (d) => |
src/web/studio/lists.ts +4 −2
| @@ -23,7 +23,7 @@ import { | ||
| 23 | 23 | repoHeader, |
| 24 | 24 | repoPalette, |
| 25 | 25 | } from "../chrome.ts"; |
| 26 | import { html, type Html } from "../html.ts"; | |
| 26 | import { html, when, type Html } from "../html.ts"; | |
| 27 | 27 | import { page, redirect } from "../layout.ts"; |
| 28 | 28 | import { liveClientJs, liveMount } from "../live.ts"; |
| 29 | 29 | import { |
| @@ -43,6 +43,7 @@ import { | ||
| 43 | 43 | studioRecord, |
| 44 | 44 | tellHref, |
| 45 | 45 | } from "./nouns.ts"; |
| 46 | import { tellLine, tellsAnswered } from "./branch_tell.ts"; | |
| 46 | 47 | import { tellWord } from "./tell.ts"; |
| 47 | 48 | import { |
| 48 | 49 | pullWord, |
| @@ -250,6 +251,7 @@ export async function studioChanges( | ||
| 250 | 251 | const words = await Promise.all( |
| 251 | 252 | fetched.map((pull) => pullWord(wordCtx, pull)), |
| 252 | 253 | ); |
| 254 | const answered = tellsAnswered(db, repo.id, fetched); | |
| 253 | 255 | const rows = fetched |
| 254 | 256 | .map((pull, i) => ({ pull, info: words[i]! })) |
| 255 | 257 | .filter((r) => inWordFilter(r.info.word, filter)); |
| @@ -269,7 +271,7 @@ export async function studioChanges( | ||
| 269 | 271 | lead: avatar(pull.authorName), |
| 270 | 272 | title: pull.title, |
| 271 | 273 | meta: html`${pull.authorName} · ${info.note} · |
| 272 | ${time(pull.updatedAt)}`, | |
| 274 | ${when(answered.get(pull.number), (t) => html`${tellLine(base, t)} · `)}${time(pull.updatedAt)}`, | |
| 273 | 275 | trail: wordBadge(info.word), |
| 274 | 276 | }), |
| 275 | 277 | ), |
test/branch_tell.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { tellNumberOf } from "../src/web/studio/branch_tell.ts"; | |
| 3 | ||
| 4 | describe("tellNumberOf", () => { | |
| 5 | test("reads the number out of <crew>/<number>-<slug>", () => { | |
| 6 | expect(tellNumberOf("scout/13-after-new-app-land")).toBe(13); | |
| 7 | expect(tellNumberOf("mason/4-update-from-parent")).toBe(4); | |
| 8 | }); | |
| 9 | test("a slug containing digits does not change the number", () => { | |
| 10 | expect(tellNumberOf("scout/13-fix-404-on-2fa")).toBe(13); | |
| 11 | }); | |
| 12 | test("anything else is no tell", () => { | |
| 13 | for (const b of [ | |
| 14 | "main", | |
| 15 | "revert-22", | |
| 16 | "feature/door-test", | |
| 17 | "scout/no-number", | |
| 18 | "scout/13", | |
| 19 | "scout/0-zero", | |
| 20 | "scout/013-padded", | |
| 21 | "scout/99999999999999999-huge", | |
| 22 | "/13-x", | |
| 23 | "", | |
| 24 | ]) | |
| 25 | expect(tellNumberOf(b)).toBeUndefined(); | |
| 26 | }); | |
| 27 | }); |
test/studio.test.ts +52
| @@ -1682,6 +1682,58 @@ describe("the studio", () => { | ||
| 1682 | 1682 | await rm(dataDir2, { recursive: true, force: true }); |
| 1683 | 1683 | } |
| 1684 | 1684 | }); |
| 1685 | test("a change on <crew>/<number>-<slug> links the tell it answers, and says so when that tell is closed; other branches show no tell", async () => { | |
| 1686 | const told = openIssue(app, alice, repo, { | |
| 1687 | title: "Answered tell", | |
| 1688 | body: "", | |
| 1689 | }); | |
| 1690 | if (!told.ok) throw new Error(String(told.error)); | |
| 1691 | const n = told.value.number; | |
| 1692 | const open = async (branch: string, title: string) => { | |
| 1693 | const c = await commitToBranch(app, alice, repo, { | |
| 1694 | branch, | |
| 1695 | base: "main", | |
| 1696 | message: title, | |
| 1697 | files: [{ path: `${branch.replace(/\//g, "_")}.md`, content: "x\n" }], | |
| 1698 | }); | |
| 1699 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 1700 | const o = await openPull(app, alice, repo, { | |
| 1701 | title, | |
| 1702 | body: "", | |
| 1703 | baseBranch: "main", | |
| 1704 | headBranch: branch, | |
| 1705 | }); | |
| 1706 | if (!o.ok) throw new Error(String(o.error)); | |
| 1707 | return o.value; | |
| 1708 | }; | |
| 1709 | const answering = await open(`scout/${n}-answer-it`, "Answers the tell"); | |
| 1710 | const stray = await open("scout/99999-nothing", "Answers nothing"); | |
| 1711 | const own = await open("revert-22", "A person's branch"); | |
| 1712 | ||
| 1713 | const list = await (await get("/alice/notes/changes")).text(); | |
| 1714 | const rowOf = (title: string) => { | |
| 1715 | const at = list.indexOf(title); | |
| 1716 | return list.slice(at, list.indexOf("</a>", at + 400) + 4); | |
| 1717 | }; | |
| 1718 | expect(rowOf("Answers the tell")).toContain(`href="/alice/notes/tells/${n}"`); | |
| 1719 | expect(rowOf("Answers the tell")).not.toContain("already done"); | |
| 1720 | expect(rowOf("Answers nothing")).not.toContain("for tell"); | |
| 1721 | expect(rowOf("A person's branch")).not.toContain("for tell"); | |
| 1722 | ||
| 1723 | setIssueState(app, alice, repo, told.value, "closed"); | |
| 1724 | const closedList = await (await get("/alice/notes/changes")).text(); | |
| 1725 | expect(closedList).toContain("its tell is already done"); | |
| 1726 | const page = await ( | |
| 1727 | await get(`/alice/notes/changes/${answering.number}`) | |
| 1728 | ).text(); | |
| 1729 | expect(page).toContain(`href="/alice/notes/tells/${n}"`); | |
| 1730 | expect(page).toContain("its tell is already done"); | |
| 1731 | for (const p of [stray, own]) { | |
| 1732 | const res = await get(`/alice/notes/changes/${p.number}`); | |
| 1733 | expect(res.status).toBe(200); | |
| 1734 | expect(await res.text()).not.toContain("for tell"); | |
| 1735 | } | |
| 1736 | }); | |
| 1685 | 1737 | }); |
| 1686 | 1738 | |
| 1687 | 1739 | describe("an owner's page lists an app once, not once in each vocabulary (#221)", () => { |
new above
offline