Public: anyone can read this; the crew can change it.
Calm pass: issues, the lists and runs show rows a reader can scan, not notes #10415
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10301-calm-pass-issues-the-lists-and-runs-show → main 6 commits fork 4 threads · 4 unresolved
14 files
changed +85
−186
docs/design/areas/issues-list.md +9
| @@ -24,3 +24,12 @@ The side column of an issue (`labelsCard`, `assigneeCard`, `issueTeamsCard`, `de | ||
| 24 | 24 | `src/web/pages/thread.ts`) reads like the pull page's side: a small heading over its rows, `.card.bare`, no box and |
| 25 | 25 | no hairline. Chips, forms and empty lines are unchanged; titles stay escaped. The pull page draws the same label and |
| 26 | 26 | assignee cards, so it follows. No new class. `test/issue-side-bare.test.ts` holds the shape. |
| 27 | ||
| 28 | ## Calm: a row is a title, a state word and a time (#10415) | |
| 29 | ||
| 30 | On the Issues, Pulls and Runs lists (`src/web/studio/lists.ts`) a row carries its title, its state word and one time. | |
| 31 | Gone from the row, still on the page it opens: the Flow-first button (still on the issue page, in its header), a pull's pipeline | |
| 32 | step and the tell it answers, a run's sha, wait reason and duration, who has an issue. A stopped pull, a failed run and a run paused on a limit keep one | |
| 33 | clamped reason line; an imported issue keeps its origin tag. The issue page drops "nobody has it yet" (the state word says it). Empty states are one line | |
| 34 | (`Nothing open.`; a set filter is named). First-view budgets fell: issue 76→72 words, issues list 32→30, | |
| 35 | pulls list 31→24, runs 46→38. No new class. |
docs/design/attention-budget.json +12 −12
| @@ -49,48 +49,48 @@ | ||
| 49 | 49 | }, |
| 50 | 50 | "issue": { |
| 51 | 51 | "390": { |
| 52 | "words": 76, | |
| 52 | "words": 72, | |
| 53 | 53 | "blocks": 19, |
| 54 | 54 | "colours": 2 |
| 55 | 55 | }, |
| 56 | 56 | "1280": { |
| 57 | "words": 76, | |
| 57 | "words": 72, | |
| 58 | 58 | "blocks": 19, |
| 59 | 59 | "colours": 2 |
| 60 | 60 | } |
| 61 | 61 | }, |
| 62 | 62 | "issues list": { |
| 63 | 63 | "390": { |
| 64 | "words": 32, | |
| 65 | "blocks": 8, | |
| 64 | "words": 30, | |
| 65 | "blocks": 7, | |
| 66 | 66 | "colours": 2 |
| 67 | 67 | }, |
| 68 | 68 | "1280": { |
| 69 | "words": 32, | |
| 70 | "blocks": 8, | |
| 69 | "words": 30, | |
| 70 | "blocks": 7, | |
| 71 | 71 | "colours": 2 |
| 72 | 72 | } |
| 73 | 73 | }, |
| 74 | 74 | "pulls list": { |
| 75 | 75 | "390": { |
| 76 | "words": 31, | |
| 77 | "blocks": 7, | |
| 76 | "words": 24, | |
| 77 | "blocks": 6, | |
| 78 | 78 | "colours": 1 |
| 79 | 79 | }, |
| 80 | 80 | "1280": { |
| 81 | "words": 31, | |
| 82 | "blocks": 7, | |
| 81 | "words": 24, | |
| 82 | "blocks": 6, | |
| 83 | 83 | "colours": 1 |
| 84 | 84 | } |
| 85 | 85 | }, |
| 86 | 86 | "runs": { |
| 87 | 87 | "390": { |
| 88 | "words": 46, | |
| 88 | "words": 38, | |
| 89 | 89 | "blocks": 8, |
| 90 | 90 | "colours": 1 |
| 91 | 91 | }, |
| 92 | 92 | "1280": { |
| 93 | "words": 46, | |
| 93 | "words": 38, | |
| 94 | 94 | "blocks": 8, |
| 95 | 95 | "colours": 1 |
| 96 | 96 | } |
docs/features/attention.md +4 −4
| @@ -25,10 +25,10 @@ reader sees on first view at 390 px and 1280 px: **words**, **text blocks** and | ||
| 25 | 25 | | repository | 116 / 18 / 2 | 116 / 18 / 2 | |
| 26 | 26 | | app | 116 / 18 / 2 | 116 / 18 / 2 | |
| 27 | 27 | | pull | 133 / 22 / 4 | 133 / 22 / 4 | |
| 28 | | issue | 76 / 19 / 2 | 76 / 19 / 2 | | |
| 29 | | issues list | 32 / 8 / 2 | 32 / 8 / 2 | | |
| 30 | | pulls list | 31 / 7 / 1 | 31 / 7 / 1 | | |
| 31 | | runs | 46 / 8 / 1 | 46 / 8 / 1 | | |
| 28 | | issue | 72 / 19 / 2 | 72 / 19 / 2 | | |
| 29 | | issues list | 30 / 7 / 2 | 30 / 7 / 2 | | |
| 30 | | pulls list | 24 / 6 / 1 | 24 / 6 / 1 | | |
| 31 | | runs | 38 / 8 / 1 | 38 / 8 / 1 | | |
| 32 | 32 | | run | 34 / 11 / 3 | 34 / 11 / 3 | |
| 33 | 33 | | settings | 137 / 32 / 1 | 137 / 32 / 1 | |
| 34 | 34 |
src/kernel/why_now.ts deleted −37
| @@ -1,37 +0,0 @@ | ||
| 1 | /** | |
| 2 | * Retired 2026-11-09: the direction no longer puts the pipeline first — | |
| 3 | * order is set by the pillars and the captain's ranking — so the gate no | |
| 4 | * longer asks (`scripts/gate.ts`). A finished, approved pull closed unmerged | |
| 5 | * on this rule (#10317); nothing reads it now. Kept with its test until the | |
| 6 | * next sweep of unused modules. | |
| 7 | * | |
| 8 | * The direction issue put the pipeline first (#8839), so a new feature | |
| 9 | * said why it went ahead of it. A feature is a file a pull adds, at any | |
| 10 | * depth, under an action, a page, an API route, an MCP tool or a terminal | |
| 11 | * screen; the pull's commits must then say either `Pipeline: #N` (it is | |
| 12 | * pipeline work) or `Why now: <reason>` (it goes ahead, and why). The check | |
| 13 | * cannot read the pull description, so the answer lives in a commit | |
| 14 | * message. It is a nudge, not a proof: it checks a reason was written, not | |
| 15 | * that it is a good one. | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Change } from "./pin_edits.ts"; | |
| 19 | ||
| 20 | const FEATURE = /^src\/(actions|web\/pages|http\/api|http\/mcp\/tools|tui)\/.+\.ts$/; | |
| 21 | const PIPELINE = /^Pipeline:\s*#\d+/im; | |
| 22 | const WHY_NOW = /^Why now:\s*\S[^\n]{14,}/im; | |
| 23 | ||
| 24 | /** The files a pull adds that make a new feature. */ | |
| 25 | export function featuresAdded(changes: readonly Change[]): string[] { | |
| 26 | return changes.filter((c) => c.status.startsWith("A") && FEATURE.test(c.path)).map((c) => c.path); | |
| 27 | } | |
| 28 | ||
| 29 | /** One line per problem: none when no feature is added or a commit answers the direction. */ | |
| 30 | export function whyNowProblems(changes: readonly Change[], messages: readonly string[]): string[] { | |
| 31 | const added = featuresAdded(changes); | |
| 32 | if (added.length === 0) return []; | |
| 33 | if (messages.some((m) => PIPELINE.test(m) || WHY_NOW.test(m))) return []; | |
| 34 | return [ | |
| 35 | `rule "say why it goes ahead of the pipeline": this pull adds ${added.join(", ")} but no commit message has a "Why now: <reason>" line (15+ characters) or a "Pipeline: #N" line`, | |
| 36 | ]; | |
| 37 | } |
src/web/studio/lists.ts +23 −73
| @@ -20,11 +20,8 @@ import { | ||
| 20 | 20 | type Label, |
| 21 | 21 | } from "../../core/issues.ts"; |
| 22 | 22 | import { listPulls, openPullsClosing } from "../../core/pulls.ts"; |
| 23 | import { stepsOfStored, type PathStep } from "../../flow/path.ts"; | |
| 24 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; | |
| 25 | import { FLOW_LABEL } from "../../core/flow_label.ts"; | |
| 26 | import { flowButton } from "../flow_button.ts"; | |
| 27 | import { stepLink } from "./step_link.ts"; | |
| 23 | import { stepsOfStored } from "../../flow/path.ts"; | |
| 24 | import { canRead, type Repo } from "../../core/repos.ts"; | |
| 28 | 25 | import { waitingCount } from "../../actions/reports.ts"; |
| 29 | 26 | |
| 30 | 27 | import { listRepoRuns } from "../../actions/runs.ts"; |
| @@ -60,10 +57,9 @@ import { | ||
| 60 | 57 | time, |
| 61 | 58 | } from "../ui.ts"; |
| 62 | 59 | import type { Run, RunState } from "../../core/runs.ts"; |
| 63 | import { actorsFor, durationText, issuesFor, runTitleFrom } from "../pages/runs.ts"; | |
| 60 | import { actorsFor, issuesFor, runTitleFrom } from "../pages/runs.ts"; | |
| 64 | 61 | import { originGlyph } from "../pages/thread.ts"; |
| 65 | 62 | import { changeHref, PAGE_TITLES, pageTitle, tellHref } from "./nouns.ts"; |
| 66 | import { tellLine, tellsAnswered } from "./branch_tell.ts"; | |
| 67 | 63 | import { knowMergeClosings, tellWords } from "./tell.ts"; |
| 68 | 64 | import { |
| 69 | 65 | pullWords, |
| @@ -447,18 +443,16 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 447 | 443 | .map((issue, i) => ({ issue, info: words[i]! })) |
| 448 | 444 | .filter((r) => shown(r.info.word, f)); |
| 449 | 445 | |
| 450 | const mayLabel = canWrite(db, repo, ctx.auth.user); | |
| 451 | 446 | const list = |
| 452 | 447 | rows.length === 0 |
| 453 | 448 | ? empty({ |
| 454 | 449 | icon: "issue", |
| 455 | title: "Nothing here", | |
| 456 | note: emptyNote( | |
| 450 | title: emptyNote( | |
| 457 | 451 | f.word, |
| 458 | 452 | "No issue", |
| 459 | 453 | record === undefined |
| 460 | 454 | ? "Nothing open." |
| 461 | : "Nothing open. Tell the crew what you want and it shows up here.", | |
| 455 | : "Nothing open. Tell the crew what you want.", | |
| 462 | 456 | ), |
| 463 | 457 | }) |
| 464 | 458 | : listCard( |
| @@ -472,18 +466,7 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 472 | 466 | meta: html`${wordText(info.word)}${when( |
| 473 | 467 | issue.originAuthor, |
| 474 | 468 | (login) => html`${originTag(login, originGlyph(repo))} · `, |
| 475 | )}${issue.assigneeName | |
| 476 | ? html`${issue.assigneeName} · ` | |
| 477 | : null}${time(issue.updatedAt)}`, | |
| 478 | ...(mayLabel && issue.state === "open" | |
| 479 | ? { | |
| 480 | after: flowButton( | |
| 481 | `${base}/issues/${issue.number}/flow`, | |
| 482 | (labels.get(issue.id) ?? []).some((l) => l.name.toLowerCase() === FLOW_LABEL), | |
| 483 | `${ctx.url.pathname}${ctx.url.search}`, | |
| 484 | ), | |
| 485 | } | |
| 486 | : {}), | |
| 469 | )}${time(issue.updatedAt)}`, | |
| 487 | 470 | }), |
| 488 | 471 | ), |
| 489 | 472 | { extra: "bare" }, |
| @@ -575,11 +558,6 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 575 | 558 | return response; |
| 576 | 559 | } |
| 577 | 560 | |
| 578 | /** The pull's step beside its row, as a sibling of the row's link: `after`. */ | |
| 579 | function stepAfter(step: PathStep | null | undefined): { after?: Html } { | |
| 580 | return step == null ? {} : { after: stepLink(step) }; | |
| 581 | } | |
| 582 | ||
| 583 | 561 | /** |
| 584 | 562 | * A pull's title and branch are the writer's words, so inside `[data-pulls]` |
| 585 | 563 | * a row wraps them (a long word breaks anywhere) in place of the list's |
| @@ -620,8 +598,6 @@ export async function changesList(ctx: Ctx): Promise<Response> { | ||
| 620 | 598 | lap("pulls"); |
| 621 | 599 | const words = await pullWords(wordCtx, fetched); |
| 622 | 600 | lap("words"); |
| 623 | const answered = tellsAnswered(db, repo.id, fetched); | |
| 624 | lap("tells"); | |
| 625 | 601 | const labels = labelsForIssues(db, fetched.map((pull) => pull.id)); |
| 626 | 602 | const repoLabels = listLabels(db, repo.id); |
| 627 | 603 | lap("labels"); |
| @@ -629,20 +605,22 @@ export async function changesList(ctx: Ctx): Promise<Response> { | ||
| 629 | 605 | .map((pull, i) => ({ pull, info: words[i]! })) |
| 630 | 606 | // An open pull is always listed, whatever its word: the tab counts them all. |
| 631 | 607 | .filter((r) => f.word === undefined || shown(r.info.word, f)); |
| 632 | const steps = stepsOfStored(db, repo, rows.map((r) => r.pull)); | |
| 633 | lap("steps"); | |
| 608 | ||
| 609 | // Only a stopped pull's row says why: the step's sentence, one clamped line. | |
| 610 | const stopped = stepsOfStored( | |
| 611 | db, | |
| 612 | repo, | |
| 613 | rows.filter((r) => r.info.word === "stopped").map((r) => r.pull), | |
| 614 | ); | |
| 634 | 615 | |
| 635 | 616 | const list = |
| 636 | 617 | rows.length === 0 |
| 637 | 618 | ? empty({ |
| 638 | 619 | icon: "pull", |
| 639 | title: "Nothing here", | |
| 640 | note: emptyNote( | |
| 620 | title: emptyNote( | |
| 641 | 621 | f.word, |
| 642 | 622 | "No pull request", |
| 643 | record === undefined | |
| 644 | ? "Nothing open." | |
| 645 | : "Nothing open. A pull request is the crew's proposed edit to your app; the crew opens them as it works.", | |
| 623 | "Nothing open.", | |
| 646 | 624 | ), |
| 647 | 625 | }) |
| 648 | 626 | : listCard( |
| @@ -653,11 +631,7 @@ export async function changesList(ctx: Ctx): Promise<Response> { | ||
| 653 | 631 | lead: wordDot(info.word, info.note), |
| 654 | 632 | title: titled(pull.title, labels.get(pull.id)), |
| 655 | 633 | titleAttr: pull.title, |
| 656 | meta: html`${wordText(info.word)}${pull.originAuthor | |
| 657 | ? originTag(pull.originAuthor, originGlyph(repo)) | |
| 658 | : pull.authorName} · | |
| 659 | ${when(answered.get(pull.number), (t) => html`${tellLine(base, t)} · `)}${time(pull.updatedAt)}`, | |
| 660 | ...stepAfter(steps.get(pull.id)), | |
| 634 | meta: html`${wordText(info.word)}${when(info.word === "stopped" ? stopped.get(pull.id) : undefined, (step) => html`${noteLine(step.why)} · `)}${time(pull.updatedAt)}`, | |
| 661 | 635 | }), |
| 662 | 636 | ), |
| 663 | 637 | ); |
| @@ -747,18 +721,6 @@ function runsHref( | ||
| 747 | 721 | return text === "" ? `${base}/runs` : `${base}/runs?${text}`; |
| 748 | 722 | } |
| 749 | 723 | |
| 750 | /** What a queued run waits on, in the words its row says it by: a builder | |
| 751 | * for an agent run, a slot for a check, the deploy lane for a deploy; a run | |
| 752 | * paused on a limit says it resumes later. */ | |
| 753 | export function whatRunWaitsOn(run: Run): string { | |
| 754 | if (run.notBefore !== null) return "paused on a limit, resumes soon"; | |
| 755 | return run.kind === "agent" | |
| 756 | ? "waiting for a builder" | |
| 757 | : run.kind === "check" | |
| 758 | ? "waiting for a check slot" | |
| 759 | : "waiting for the deploy lane"; | |
| 760 | } | |
| 761 | ||
| 762 | 724 | /** Whether a run is doing something at this moment — the only runs under |
| 763 | 725 | * Working. A deploy is not counted: the flow report's stages are builders, |
| 764 | 726 | * reviewers and checks, and the tab's count is held to theirs. */ |
| @@ -782,14 +744,6 @@ export function onRunsList( | ||
| 782 | 744 | return inWordFilter(word, filter); |
| 783 | 745 | } |
| 784 | 746 | |
| 785 | /** A check's state in the words its row names it by. */ | |
| 786 | const checkState = (run: Run): string => | |
| 787 | run.state === "ok" | |
| 788 | ? "passed" | |
| 789 | : run.state === "queued" | |
| 790 | ? whatRunWaitsOn(run) | |
| 791 | : run.state; | |
| 792 | ||
| 793 | 747 | const RUNS_KIND_WORDS: Record<RunsKind, string> = { |
| 794 | 748 | agent: "Agent runs", |
| 795 | 749 | deploy: "Deploys", |
| @@ -851,13 +805,10 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 851 | 805 | rows.length === 0 |
| 852 | 806 | ? empty({ |
| 853 | 807 | icon: "bot", |
| 854 | title: "Nothing here", | |
| 855 | note: emptyNote( | |
| 808 | title: emptyNote( | |
| 856 | 809 | filter, |
| 857 | 810 | "No run", |
| 858 | kind === undefined || kind === "agent" | |
| 859 | ? "Nothing open. A run is one go the crew takes at a job; each one shows up here." | |
| 860 | : `Nothing open. Every ${kind === "deploy" ? "deploy" : "check"} shows up here while it runs.`, | |
| 811 | "Nothing open.", | |
| 861 | 812 | ), |
| 862 | 813 | }) |
| 863 | 814 | : listCard( |
| @@ -875,12 +826,11 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> { | ||
| 875 | 826 | : icon(run.kind === "check" ? "check" : "rocket"), |
| 876 | 827 | title: runTitleFrom(run, actors, issues), |
| 877 | 828 | meta: |
| 878 | run.kind === "check" | |
| 879 | ? html`${run.commitOid.slice(0, 7)} · ${checkState(run)} | |
| 880 | ${when(durationText(run), (d) => html` · took ${d}`)} · | |
| 881 | ${time(run.finishedAt ?? run.createdAt)}` | |
| 882 | : html`${run.kind === "agent" ? html`${who} · ` : null}${run.state === "queued" ? whatRunWaitsOn(run) : noteLine(info.note)} | |
| 883 | · ${time(run.finishedAt ?? run.createdAt)}`, | |
| 829 | run.state === "failed" | |
| 830 | ? html`${noteLine(info.note)} · ${time(run.finishedAt ?? run.createdAt)}` | |
| 831 | : run.state === "queued" && run.notBefore !== null | |
| 832 | ? html`${noteLine("paused on a limit, resumes soon")} · ${time(run.createdAt)}` | |
| 833 | : time(run.finishedAt ?? run.createdAt), | |
| 884 | 834 | trail: wordBadge(info.word), |
| 885 | 835 | }); |
| 886 | 836 | }), |
src/web/studio/tell.ts +1 −3
| @@ -540,9 +540,7 @@ async function studioTell( | ||
| 540 | 540 | })} |
| 541 | 541 | has it</span |
| 542 | 542 | >` |
| 543 | : !direction && wordInfo?.word === "told" | |
| 544 | ? html`<span class="muted">nobody has it yet</span>` | |
| 545 | : null} | |
| 543 | : null} | |
| 546 | 544 | </div> |
| 547 | 545 | </div>`; |
| 548 | 546 |
test/flow-first.test.ts +2 −1
| @@ -109,7 +109,8 @@ describe("the flow label goes first", () => { | ||
| 109 | 109 | const rw = createToken(app.db, alice.id, "rw", "rw").token; |
| 110 | 110 | const cookie = { authorization: `Bearer ${rw}` }; |
| 111 | 111 | const list = await (await fetch(`${servers.url}/alice/svc/issues`, { headers: cookie })).text(); |
| 112 | expect(list).toContain(`action="/alice/svc/issues/${target.number}/flow"`); | |
| 112 | // The list's rows are title, word and time only (#10415); the button is on the issue page, in its header. | |
| 113 | expect(list).not.toContain(`action="/alice/svc/issues/${target.number}/flow"`); | |
| 113 | 114 | const page = await (await fetch(`${servers.url}/alice/svc/issues/${target.number}`, { headers: cookie })).text(); |
| 114 | 115 | expect(page).toContain(`/alice/svc/issues/${target.number}/flow`); |
| 115 | 116 | expect(page).toContain("Flow first"); |
test/pull-step-surfaces.test.ts +7 −4
| @@ -93,8 +93,9 @@ test("the feed's post for a pull says its step, linked to /pipeline", async () = | ||
| 93 | 93 | expect(await get("/feed")).toContain(expected()); |
| 94 | 94 | }); |
| 95 | 95 | |
| 96 | test("the repository's pulls list says each pull's step", async () => { | |
| 97 | expect(await get("/alice/site/pulls")).toContain(expected()); | |
| 96 | test("the repository's pulls list leaves the step to the pull (#10415)", async () => { | |
| 97 | expect(await get("/alice/site/pulls")).not.toContain(expected()); | |
| 98 | expect(await get(`/alice/site/pulls/${pull.number}`)).toContain('data-path="review"'); | |
| 98 | 99 | }); |
| 99 | 100 | |
| 100 | 101 | const openMore = async (r: Repo, by: User, name: string): Promise<Pull> => { |
| @@ -109,12 +110,14 @@ const openMore = async (r: Repo, by: User, name: string): Promise<Pull> => { | ||
| 109 | 110 | return opened.value; |
| 110 | 111 | }; |
| 111 | 112 | |
| 112 | test("a long list says every pull's step and stays quick", async () => { | |
| 113 | test("a long list is scannable rows with no step beside them, and stays quick", async () => { | |
| 113 | 114 | for (let i = 0; i < 24; i++) await openMore(repo, alice, `long${i}`); |
| 114 | 115 | const started = performance.now(); |
| 115 | 116 | const html = await get("/alice/site/pulls"); |
| 116 | 117 | const took = performance.now() - started; |
| 117 | expect(html.match(/data-step="review"/g)?.length).toBeGreaterThanOrEqual(25); | |
| 118 | // The calm pass (#10415): a row is a title, a state word and a time; the step is on the pull. | |
| 119 | expect(html.match(/class="row-title"/g)?.length).toBeGreaterThanOrEqual(25); | |
| 120 | expect(html).not.toContain('data-step='); | |
| 118 | 121 | expect(took).toBeLessThan(500); |
| 119 | 122 | }); |
| 120 | 123 |
test/pulls-list-timing.test.ts +2 −2
| @@ -38,10 +38,10 @@ function names(res: Response): string[] { | ||
| 38 | 38 | return requestTiming(res).split(", ").map((p) => p.split(";")[0]!); |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | test("a repository's pulls list names session, repo, pulls, words, tells, labels, steps and render in order", async () => { | |
| 41 | test("a repository's pulls list names session, repo, pulls, words, labels and render in order", async () => { | |
| 42 | 42 | const res = await fetch(`${servers.url}/alice/notes/pulls`, { headers: { authorization: `Bearer ${bearer}` } }); |
| 43 | 43 | expect(res.status).toBe(200); |
| 44 | 44 | await res.text(); |
| 45 | 45 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); |
| 46 | expect(names(res)).toEqual(["app", "session", "repo", "pulls", "words", "tells", "labels", "steps", "render"]); | |
| 46 | expect(names(res)).toEqual(["app", "session", "repo", "pulls", "words", "labels", "render"]); | |
| 47 | 47 | }); |
test/runs-working-tab.test.ts +5 −9
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The Runs page's Working tab lists only runs doing something now, and its |
| 3 | 3 | * count is the flow report's building + review + checking on the same data; |
| 4 | * queued runs say what they wait on, finished ones are not under Working (#7719). | |
| 4 | * queued runs are listed by state, finished ones are not under Working (#7719). | |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| @@ -89,14 +89,10 @@ test("Working counts what the flow report counts as active, and nothing finished | ||
| 89 | 89 | expect(body).not.toContain("waiting for"); |
| 90 | 90 | }); |
| 91 | 91 | |
| 92 | test("a queued run says what it waits on", async () => { | |
| 93 | const body = await page(""); | |
| 94 | expect(body).toContain("waiting for a builder"); | |
| 95 | expect(body).toContain("waiting for a check slot"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a paused run and a queued deploy say what they wait on", async () => { | |
| 92 | test("a queued run is listed by its state word; only a paused one says why", async () => { | |
| 99 | 93 | const body = await page("?state=queued"); |
| 94 | expect(rowCount(body)).toBeGreaterThan(0); | |
| 95 | expect(body).toContain("queued"); | |
| 100 | 96 | expect(body).toContain("paused on a limit, resumes soon"); |
| 101 | expect(body).toContain("waiting for the deploy lane"); | |
| 97 | expect(body).not.toContain("waiting for"); | |
| 102 | 98 | }); |
test/studio-changes.test.ts +17 −9
| @@ -340,8 +340,11 @@ describe("the studio, continued", () => { | ||
| 340 | 340 | const all = await (await get("/alice/notes/runs")).text(); |
| 341 | 341 | for (const link of [agent, check, deploy]) expect(all).toContain(link); |
| 342 | 342 | expect(all).toContain("Check of abcdef1"); |
| 343 | expect(all).toContain("abcdef1 · passed"); | |
| 344 | expect(all).toContain("took "); | |
| 343 | // A row is title, state word and time (#10415): no sha, no duration beside it. | |
| 344 | expect(all).not.toContain("abcdef1 · passed"); | |
| 345 | expect(all).toContain("<time"); | |
| 346 | const checkId = /runs\/([^"]+)"/.exec(check)![1]; | |
| 347 | expect(await (await get(`/alice/notes/runs/${checkId}`)).text()).toContain("abcdef1"); | |
| 345 | 348 | |
| 346 | 349 | const only = async (kind: string) => |
| 347 | 350 | (await get(`/alice/notes/runs?kind=${kind}`)).text(); |
| @@ -683,8 +686,8 @@ describe("the studio, continued", () => { | ||
| 683 | 686 | const tellHtml = await viaTell.text(); |
| 684 | 687 | expect(tellHtml).toContain("Door test: one tell"); |
| 685 | 688 | expect(tellHtml).toContain('data-word="told"'); |
| 686 | // Still waiting, nobody assigned: the one case the line is true (#214). | |
| 687 | expect(tellHtml).toContain("nobody has it yet"); | |
| 689 | // Still waiting: the state word says so; the page adds no second line (#10415). | |
| 690 | expect(tellHtml).not.toContain("nobody has it yet"); | |
| 688 | 691 | // The reply, the close and every Details form post to /issues/:number. |
| 689 | 692 | expect(tellHtml).toContain(`action="/alice/notes/issues/${n}/comments"`); |
| 690 | 693 | expect(tellHtml).toContain(`action="/alice/notes/issues/${n}/assignee"`); |
| @@ -1319,14 +1322,19 @@ describe("the studio, continued", () => { | ||
| 1319 | 1322 | const at = list.indexOf(title); |
| 1320 | 1323 | return list.slice(at, list.indexOf("</a>", at + 400) + 4); |
| 1321 | 1324 | }; |
| 1322 | expect(rowOf("Answers the tell")).toContain(`href="/alice/notes/issues/${n}"`); | |
| 1323 | expect(rowOf("Answers the tell")).not.toContain("already done"); | |
| 1324 | expect(rowOf("Answers nothing")).not.toContain("for issue"); | |
| 1325 | expect(rowOf("A person's branch")).not.toContain("for issue"); | |
| 1325 | // The calm pass (#10415): a row is title, word and time; the tell it answers is on the pull. | |
outdated
This negative passes without the change. Assert positively that the pull page carries the tell/step the list dropped. |
||
| 1326 | for (const title of ["Answers the tell", "Answers nothing", "A person's branch"]) { | |
| 1327 | expect(rowOf(title.replace("'", "'"))).toContain("<time"); | |
| 1328 | expect(rowOf(title.replace("'", "'"))).not.toContain("for issue"); | |
| 1329 | } | |
| 1330 | expect(rowOf("Answers the tell")).toContain('data-word='); | |
| 1331 | expect( | |
| 1332 | await (await get(`/alice/notes/pulls/${answering.number}`)).text(), | |
| 1333 | ).toContain(`href="/alice/notes/issues/${n}"`); | |
| 1326 | 1334 | |
| 1327 | 1335 | setIssueState(app, alice, repo, told.value, "closed"); |
| 1328 | 1336 | const closedList = await (await get("/alice/notes/pulls")).text(); |
| 1329 | expect(closedList).toContain("its issue is already done"); | |
| 1337 | expect(closedList).not.toContain("its issue is already done"); | |
| 1330 | 1338 | const page = await ( |
| 1331 | 1339 | await get(`/alice/notes/pulls/${answering.number}`) |
| 1332 | 1340 | ).text(); |
test/studio-search.test.ts +2 −3
| @@ -1248,11 +1248,10 @@ describe("the studio's empty lists", () => { | ||
| 1248 | 1248 | await setup("blank"); |
| 1249 | 1249 | for (const [path, words] of [ |
| 1250 | 1250 | ["issues", "Tell the crew what you want"], |
| 1251 | ["pulls", "A pull request is the crew"], | |
| 1252 | ["runs", "A run is one go"], | |
| 1251 | ["pulls", "Nothing open."], | |
| 1252 | ["runs", "Nothing open."], | |
| 1253 | 1253 | ] as const) { |
| 1254 | 1254 | const page = await get(`/alice/blank/${path}`); |
| 1255 | expect(page).toContain("Nothing here"); | |
| 1256 | 1255 | expect(page).toContain(words); |
| 1257 | 1256 | expect(page).not.toContain("this filter"); |
| 1258 | 1257 | } |
test/web-ui.test.ts +1 −1
| @@ -743,7 +743,7 @@ describe("web ui", () => { | ||
| 743 | 743 | await fetch(`${base}/mel/proj/issues?q=nothing-matches-this&state=all`) |
| 744 | 744 | ).text(); |
| 745 | 745 | expect(miss).not.toContain("needs a label"); |
| 746 | expect(miss).toContain("Nothing here"); | |
| 746 | expect(miss).toContain("Nothing open."); | |
| 747 | 747 | expect(miss).toContain("nothing-matches-this in the title ✕"); |
| 748 | 748 | |
| 749 | 749 | // A LIKE wildcard typed into the box is a literal character, not a glob. |
test/why-now.test.ts deleted −28
| @@ -1,28 +0,0 @@ | ||
| 1 | /** Retired 2026-11-09 with the pipeline-first direction: the gate no longer asks (`scripts/gate.ts`); the pure rule keeps its tests until the module goes. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { featuresAdded, whyNowProblems } from "../src/kernel/why_now.ts"; | |
| 4 | ||
| 5 | const add = (path: string) => ({ status: "A", path }); | |
| 6 | ||
| 7 | test("a new action, page or route is a feature; edits, tests and other files are not", () => { | |
| 8 | expect(featuresAdded([add("src/actions/x.ts"), add("src/web/pages/y.ts"), add("src/http/api/z.ts")])).toHaveLength(3); | |
| 9 | expect(featuresAdded([add("src/http/mcp/tools/x.ts"), add("src/tui/app/screen.ts"), add("src/http/api/v1/y.ts")])).toHaveLength(3); | |
| 10 | expect(featuresAdded([{ status: "M", path: "src/actions/x.ts" }, add("test/x.test.ts"), add("src/core/x.ts")])).toEqual([]); | |
| 11 | }); | |
| 12 | ||
| 13 | test("a feature with no answer to the direction is refused, naming the rule and the file", () => { | |
| 14 | const [p] = whyNowProblems([add("src/actions/x.ts")], ["Add x\n\nIt does x."]); | |
| 15 | expect(p).toContain("say why it goes ahead of the pipeline"); | |
| 16 | expect(p).toContain("src/actions/x.ts"); | |
| 17 | }); | |
| 18 | ||
| 19 | test("a Why now line or a Pipeline line answers it; a thin reason does not", () => { | |
| 20 | const changes = [add("src/actions/x.ts")]; | |
| 21 | expect(whyNowProblems(changes, ["Add x\n\nWhy now: the owner asked for it in #1234 this week"])).toEqual([]); | |
| 22 | expect(whyNowProblems(changes, ["Add x\n\nPipeline: #6534"])).toEqual([]); | |
| 23 | expect(whyNowProblems(changes, ["Add x\n\nWhy now: soon"])).toHaveLength(1); | |
| 24 | }); | |
| 25 | ||
| 26 | test("a pull that adds no feature is never asked", () => { | |
| 27 | expect(whyNowProblems([add("test/a.test.ts")], ["Fix a"])).toEqual([]); | |
| 28 | }); |
Dead code:
record === undefined ? "Nothing open." : "Nothing open."has two identical branches. Pass the string. Same for the runs kind ternary below. Also the stopped pull's reason is gone from the row (principle 1).