Public: anyone can read this; the crew can change it.

The feed, the pulls list and the TUI say check, review, land or live for each pull #8252

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7783-the-feed-the-pulls-list-and-the-tui-say → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/7783-the-feed-the-pulls-list-and-the-tui-say was merged into main as 25a3f77.

Review

Sign in to review this change.
19 files changed +363 −13
README.md +1 −1
@@ -399,7 +399,7 @@ work here.
399399
400400 ## What works today
401401
402 Every pull says which of four steps it is at — check, review, land, live — and why it waits there: a line under its title on both tabs (linked to `/pipeline`, a public page that explains the four steps), `step: { step, why, done }` in the MCP `pull` tool, and `step: …` in the terminal pull screen's header. See [`docs/features/pipeline.md`](docs/features/pipeline.md).
402 Every pull says which of four steps it is at — check, review, land, live — and why it waits there: a line under its title on both tabs (linked to `/pipeline`, a public page that explains the four steps), `step: { step, why, done }` in the MCP `pull` tool, and `step: …` in the terminal pull screen's header; the same word and sentence sit on the pull's post in the feed, its row on the pulls list and on `/review` (each linked to `/pipeline`), and in the terminal's feed, pulls list and on-you list (`Review — No review yet.` under the row). See [`docs/features/pipeline.md`](docs/features/pipeline.md).
403403
404404 A public app of a public repository tells an agent that lands on it what it can
405405 do: its HTML pages, and the "not running" page, carry two `Link` headers —
docs/features/pipeline.md +10 −3
@@ -34,12 +34,19 @@ Under every pull's title (both tabs) the line reads, for example,
3434 `Check · **Review** · Land · Live — No review yet.`, linked to `/pipeline`.
3535 A finished path (live, or landed where nothing deploys) carries a "done" badge.
3636 The MCP `pull` tool answers `step: { step, why, done }`, and the terminal's pull
37 screen says `step: review` beside `next:` in its header line.
37 screen says `step: Review — No review yet.` beside `next:` in its header line.
38 The same word and sentence (from `stepOfStored`) sit on a pull's post in the feed,
39 its row on the repository's pulls list and on `/review` (the word links to
40 `/pipeline`), and under its row in the terminal's feed, pulls list and on-you
41 list (#7783). List rows skip the merge-conflict probe (it costs a git run per
42 pull), so a conflicted pull can read differently on its own page than on a list;
43 the row links to the pull, whose page answers.
44 The step is looked up per row (a few queries each); batching it is left for when
45 the lists measure slow.
3846 `changeHead` computes "who acts next" once and hands it to `stepOfPull`.
3947
4048 ## Not here yet
4149
4250 The rest of #7703 is filed as its own issues: remove the proof (#7779), run
4351 the full suite as a scheduled report (#7780), re-run a shard that crashed
44 outside any test (#7781), reuse a verdict for the same tree (#7782), carry the
45 step to the feed and the lists (#7783; the pull page, MCP and terminal have it), and measure each step (#7784).
52 outside any test (#7781), reuse a verdict for the same tree (#7782), and measure each step (#7784).
src/feed/feed.ts +4 −1
@@ -19,6 +19,7 @@ import { writeStamp } from "../core/write_clock.ts";
1919 import { Stamped } from "../kernel/stamped.ts";
2020 import { foldBursts } from "./fold.ts";
2121 import { rank, type FeedItem } from "./rank.ts";
22 import { stepsOf } from "./step.ts";
2223 import { settledOf } from "./settled.ts";
2324
2425 export const FEED_LIMIT_DEFAULT = 20;
@@ -200,9 +201,11 @@ export function readFeedPage(
200201 const started = performance.now();
201202 const settled = settledOf(db, page);
202203 options.phase?.("settled", performance.now() - started);
204 const steps = stepsOf(db, page);
203205 const items = page.map((i) => {
204206 const state = settled.get(i.id);
205 return state === undefined ? i : { ...i, settled: state };
207 const step = steps.get(i.id);
208 return { ...i, ...(state === undefined ? {} : { settled: state }), ...(step === undefined ? {} : { step }) };
206209 });
207210 const last = items[items.length - 1];
208211 return {
src/feed/rank.ts +3
@@ -8,6 +8,7 @@
88
99 import { decay, type Affinity, type Weighted, type Why } from "./affinity.ts";
1010 import type { Candidate, FeedAsk, FeedLook } from "./items.ts";
11 import type { PathStep } from "../flow/path.ts";
1112 import type { Settled } from "./settled.ts";
1213
1314 export interface FeedItem {
@@ -32,6 +33,8 @@ export interface FeedItem {
3233 readonly look?: FeedLook;
3334 /** For a post about a pull or an issue that is over: how it ended. Absent while open. */
3435 readonly settled?: Settled;
36 /** For a post about a pull: which of check, review, land, live it is at, and why. */
37 readonly step?: PathStep;
3538 }
3639
3740 const RECENCY_MAX = 4;
src/feed/step.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * Where a pull's post stands on the one path (#7703): check, review, land
3 * or live, from `stepOfStored` — the same function the pull page reads.
4 * Only posts whose link is a pull have one; a pull closed unmerged has none.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { getRepo } from "../core/repos.ts";
9 import { stepOfNumber, type PathStep } from "../flow/path.ts";
10
11 const PULL = /^\/([^/]+)\/([^/]+)\/pulls\/(\d{1,9})$/;
12
13 export function stepsOf(
14 db: Database,
15 posts: readonly { readonly id: string; readonly kind: string; readonly link: string }[],
16 ): Map<string, PathStep> {
17 const out = new Map<string, PathStep>();
18 for (const p of posts) {
outdated

The link regex is the only tie between a post and its pull. Add a test for a post about a pull in a private repository, and for a closed-unmerged pull (no step), so nothing leaks or breaks.

19 const m = p.kind === "pull" ? PULL.exec(p.link) : null;
20 if (m === null) continue;
21 const repo = getRepo(db, m[1]!, m[2]!);
22 const step = repo ? stepOfNumber(db, repo, Number(m[3])) : null;
23 if (step) out.set(p.id, step);
24 }
25 return out;
26 }
src/flow/path.ts +18 −1
@@ -13,7 +13,7 @@
1313
1414 import type { Database } from "bun:sqlite";
1515 import { getAppByRepo } from "../core/apps.ts";
16 import type { Pull } from "../core/pulls.ts";
16 import { getPull, type Pull } from "../core/pulls.ts";
1717 import type { Repo } from "../core/repos.ts";
1818 import { reviewSummary } from "../core/reviews.ts";
1919 import { checkSummary, type RunState } from "../core/runs.ts";
@@ -125,3 +125,20 @@ export function stepOfPull(
125125 next,
126126 });
127127 }
128
129 /** The step as its heading reads: "Check". The one capitalisation every surface shares. */
130 export const stepWord = (step: Step): string => `${step.charAt(0).toUpperCase()}${step.slice(1)}`;
131
132 /** The step and why in one plain line, for the terminal and anywhere text is all there is. */
133 export const stepLine = (p: PathStep): string => `${stepWord(p.step)}${p.done ? " (done)" : ""} — ${p.why}`;
134
135 /** `stepOfPull` for a list: the head as stored, no conflict probe (a list does not open the repository). */
136 export function stepOfStored(db: Database, repo: Repo, pull: Pull): PathStep | null {
137 return stepOfPull(db, repo, pull, { headOid: pull.headOid, conflicts: 0 });
138 }
139
140 /** `stepOfStored` for the pull a repository numbers `number`; null for an issue, a missing number, or a pull closed unmerged. */
141 export function stepOfNumber(db: Database, repo: Repo, number: number): PathStep | null {
142 const pull = getPull(db, repo.id, number);
143 return pull === undefined ? null : stepOfStored(db, repo, pull);
144 }
src/tui/screens/dashboard.ts +10
@@ -53,6 +53,7 @@ import {
5353 previewAsksBulk,
5454 undoAsksBulk,
5555 } from "../../actions/asks_bulk.ts";
56 import { stepLine, stepOfNumber } from "../../flow/path.ts";
5657 import { reviewSetOf, type ReviewSet } from "../../web/studio/review_set.ts";
5758 import { staleWords } from "../../flow/kpi_words.ts";
5859 import { askMeasureWeek, asksMeasureEmpty, asksMeasureWords } from "../../core/ask_ledger.ts";
@@ -175,6 +176,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
175176 let pins: Pin[] = [];
176177 let pinned = new Set<string>();
177178 let onYou: OnYouItem[] = [];
179 /** Where each pull on you stands on the path (check, review, land, live), by issue id. */
180 let onYouSteps = new Map<string, string>();
178181 /** The week's asks line, read on reload; drawing only reads it. */
179182 let asksLine: string | undefined;
180183 /** Wave proposals that wait for this person's nod, listed after the rows above. */
@@ -335,6 +338,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
335338 pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>();
336339 all = listReposVisibleTo(f.db, f.user, { limit: 200 });
337340 onYou = assignedToMe();
341 onYouSteps = new Map(
342 onYou.flatMap((item) => {
343 const step = item.issue.kind === "pull" ? stepOfNumber(f.db, item.repo, item.issue.number) : null;
344 return step === null ? [] : [[item.issue.id, stepLine(step)] as const];
345 }),
346 );
338347 asksLine = undefined;
339348 if (f.user !== undefined && f.user.kind === "user") {
340349 const m = askMeasureWeek(f.db, f.user.id);
@@ -1316,6 +1325,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
13161325 : ISSUE_MARK;
13171326 return {
13181327 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : item.late !== undefined ? "waiting " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`,
1328 ...(onYouSteps.has(item.issue.id) ? { note: onYouSteps.get(item.issue.id)! } : {}),
13191329 meta: `${item.waiting.word} · on ${item.waiting.on}${item.passed === undefined ? "" : ` · handed to you: ${item.passed}`}${item.late?.advice ?? item.overAdvice ?? ""}`,
13201330 };
13211331 }),
src/tui/screens/feed.ts +2
@@ -17,6 +17,7 @@ import type { Ctx, Screen } from "../app/app.ts";
1717 import { binding } from "../app/keys.ts";
1818 import type { Session } from "../session.ts";
1919 import { fill, len, split } from "../ui/layout.ts";
20 import { stepLine } from "../../flow/path.ts";
2021 import { emptyState } from "../ui/widgets.ts";
2122 import { ListState, ago, drawList, errorText, runAsync, type ListRow } from "./common.ts";
2223 import type { PaletteEntry } from "./entries.ts";
@@ -86,6 +87,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen {
8687 title: oneLine(`${item.actor ?? item.app} ${item.what}`),
8788 secondary: item.app,
8889 meta: ago(item.at),
90 ...(item.step === undefined ? {} : { note: stepLine(item.step) }),
8991 }));
9092 drawList(s, list!, c, state, rows);
9193 if (composer.open) composer.render(s, box!, c, ctx);
src/tui/screens/issues.ts +10
@@ -13,6 +13,7 @@ import { openIssueThrough } from "../../actions/issues.ts";
1313 import type { IssueKind, Label } from "../../core/issues.ts";
1414 import { getIssuesByNumbers, numberKey } from "../../core/issues.ts";
1515 import { LATER, pillarsOf, servedBy, type Pillar } from "../../core/pillars.ts";
16 import { stepLine, stepOfNumber } from "../../flow/path.ts";
1617 import { mergedPullNumbers } from "../../core/pulls.ts";
1718 import { repoDir } from "../../git/repos.ts";
1819 import type { Repo } from "../../core/repos.ts";
@@ -93,6 +94,8 @@ export function issuesScreen(
9394 * set: one grouped query for the whole page, never one per row.
9495 */
9596 const verdicts = new Map<number, Verdict>();
97 /** Which of check, review, land, live each open pull is at, and why: the web row's step. */
98 const steps = new Map<number, string>();
9699 /**
97100 * The pillars of this repository and the one each row's issue serves,
98101 * read once per reload: `servedBy` answers for the whole repository in two
@@ -147,6 +150,11 @@ export function issuesScreen(
147150 numbers,
148151 ))
149152 verdicts.set(number, verdict);
153 if (repo === undefined) return;
154 for (const row of rows) {
155 const step = stepOfNumber(f.db, repo, row.number);
156 if (step !== null) steps.set(row.number, stepLine(step));
157 }
150158 ctx.invalidate();
151159 };
152160
@@ -174,6 +182,7 @@ export function issuesScreen(
174182 const reload = async (): Promise<void> => {
175183 merged.clear();
176184 verdicts.clear();
185 steps.clear();
177186 tags.clear();
178187 items = await take();
179188 loaded = true;
@@ -345,6 +354,7 @@ export function issuesScreen(
345354 ? ("open" as const)
346355 : ("closed" as const),
347356 secondary: item.labels.map((l) => l.name).join(" "),
357 ...(steps.has(item.number) ? { note: steps.get(item.number)! } : {}),
348358 // An imported row is the origin's author, marked as upstream: a
349359 // login from another forge must never read as an account here.
350360 meta: metaOf(
src/tui/screens/pull.ts +2 −2
@@ -63,7 +63,7 @@ import { checkWords } from "../../checks/summary.ts";
6363 import { oneLine } from "../../checks/words.ts";
6464 import { resolveOid } from "../../git/merge.ts";
6565 import { nextOfPull, type Next } from "../../flow/next_actor.ts";
66 import { stepOfPull, type PathStep } from "../../flow/path.ts";
66 import { stepLine, stepOfPull, type PathStep } from "../../flow/path.ts";
6767 import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts";
6868 import { voteVariant } from "../../actions/variants.ts";
6969 import { getRepoById, type Repo } from "../../core/repos.ts";
@@ -1018,7 +1018,7 @@ export function pullScreen(
10181018 x1,
10191019 head!.y + 1,
10201020 truncate(
1021 ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${step.step}${step.done ? " (done)" : ""}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`,
1021 ` ${facts?.mergedAt != null ? "merged" : issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)}${assignedText(issueRow, ctx.icons)}${facts?.proposal ? ` · ${proposedByText(facts.proposal)}` : ""}${hold === undefined ? "" : ` · held by ${hold.heldByName}: ${hold.why}`}${step === null ? "" : ` · step: ${stepLine(step)}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`,
10221022 Math.max(0, edge - x1),
10231023 ),
10241024 style(c.mutedFg, c.bg),
src/web/studio/change.ts +2 −2
@@ -85,7 +85,7 @@ import { visionPreviewCard } from "./direction.ts";
8585 import { holdCard } from "./hold.ts";
8686 import { pullMayRun } from "../../core/pull_trust.ts";
8787 import { nextOfPull } from "../../flow/next_actor.ts";
88 import { STEP_MEANS, STEPS, stepOfPull, type PathStep } from "../../flow/path.ts";
88 import { STEP_MEANS, STEPS, stepOfPull, stepWord, type PathStep } from "../../flow/path.ts";
8989 import { changeHref } from "./nouns.ts";
9090 import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts";
9191
@@ -253,7 +253,7 @@ export function changeHead(
253253 export function pathLine(p: PathStep): Html {
254254 const at = STEPS.indexOf(p.step);
255255 const words = STEPS.map((s, i) => {
256 const label = `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
256 const label = stepWord(s);
257257 if (i === at)
258258 return html`<strong aria-current="step" title="${STEP_MEANS[s]}">${label}</strong>${p.done ? html` ${badge("done", { tone: "success" })}` : null}`;
259259 return html`<span title="${STEP_MEANS[s]}">${label}</span>`;
src/web/studio/feed.ts +2
@@ -29,6 +29,7 @@ import { fragment } from "../layout.ts";
2929 import { draftJs } from "../draft.ts";
3030 import { outboxJs } from "../outbox.ts";
3131 import { avatar, btn, card, disclosure, empty, nameMark, row, stateBadge, time } from "../ui.ts";
32 import { stepLink } from "./step_link.ts";
3233 import { writableAppsOf } from "./asks.ts";
3334
3435 /** Events that can change what the feed says. */
@@ -134,6 +135,7 @@ export function feedCard(
134135 : html`<a class="look" data-slot="feed" href="${item.link}" tabindex="-1" aria-hidden="true"
135136 ><img src="${picture.src}" width="${picture.width}" height="${picture.height}" alt="" loading="lazy" decoding="async"
136137 /></a>`}
138 ${item.step === undefined ? null : html`<p class="break">${stepLink(item.step)}</p>`}
137139 ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}`;
138140 const foot = disclosure(
139141 `${reply ? "Reply or " : ""}${target ? "Steer" : "Tell"} · more`,
src/web/studio/lists.ts +11 −1
@@ -9,6 +9,7 @@
99 * `/pulls`.
1010 */
1111
12 import type { Database } from "bun:sqlite";
1213 import { sealedScope } from "../../core/sealed.ts";
1314 import { appsRuntime } from "../../apps/runtime.ts";
1415 import { getAppByRepo } from "../../core/apps.ts";
@@ -19,10 +20,12 @@ import {
1920 type Issue,
2021 type Label,
2122 } from "../../core/issues.ts";
22 import { listPulls, openPullsClosing } from "../../core/pulls.ts";
23 import { listPulls, openPullsClosing, type Pull } from "../../core/pulls.ts";
24 import { stepOfStored } from "../../flow/path.ts";
2325 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
2426 import { FLOW_LABEL } from "../../core/flow_label.ts";
2527 import { flowButton } from "../flow_button.ts";
28 import { stepLink } from "./step_link.ts";
2629 import { waitingCount } from "../../actions/reports.ts";
2730
2831 import { listRepoRuns } from "../../actions/runs.ts";
@@ -486,6 +489,12 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
486489 return response;
487490 }
488491
492 /** The pull's step beside its row, as a sibling of the row's link: `after`. */
493 function stepAfter(db: Database, repo: Repo, pull: Pull): { after?: Html } {
494 const step = stepOfStored(db, repo, pull);
495 return step === null ? {} : { after: stepLink(step) };
496 }
497
489498 /**
490499 * GET /:owner/:name/pulls: every pull request, for every
491500 * repository and every viewer — one row per pull in its studio word, who
outdated

stepAfter is inserted between changesList's JSDoc and the function, so the doc comment now documents stepAfter. Move the helper above that comment. Also group the new imports (bun:sqlite type, Pull) with the existing ones rather than mid-list.

@@ -552,6 +561,7 @@ export async function changesList(ctx: Ctx): Promise<Response> {
552561 : pull.authorName} · ${info.note} ·
553562 ${when(answered.get(pull.number), (t) => html`${tellLine(base, t)} · `)}${time(pull.updatedAt)}`,
554563 trail: wordBadge(info.word),
564 ...stepAfter(db, repo, pull),
555565 }),
outdated

stepOfStored runs per pull row, which means per-row queries on the list. Check it stays in the fast path for a long list, or batch it as stepsOf does. Add a test with several pulls.

556566 ),
557567 );
src/web/studio/review_page.ts +4
@@ -18,6 +18,8 @@ import { html, type Html } from "../html.ts";
1818 import { page } from "../layout.ts";
1919 import { badge, empty, listCard, row, runStateBadge, time } from "../ui.ts";
2020 import { lookThumb, lookThumbPlain, thumbLook } from "../pages/looks.ts";
21 import { stepOfStored } from "../../flow/path.ts";
22 import { stepLink } from "./step_link.ts";
2123 import { actRow, rowId } from "./review_act.ts";
2224 import { reviewSet } from "./review_set.ts";
2325 import { staleArchive } from "./review_stale.ts";
@@ -120,6 +122,7 @@ export function reviewPage(ctx: Ctx): Response {
120122 ? " · Diff size unavailable — open the pull to see it"
121123 : "";
122124 const act = actRow(a);
125 const step = a.pull === undefined ? null : stepOfStored(ctx.app.db, a.repo, a.pull);
123126 return row({
124127 href,
125128 nav: true,
@@ -128,6 +131,7 @@ export function reviewPage(ctx: Ctx): Response {
128131 title: a.pull?.title ?? a.issue?.title ?? a.proposal?.title ?? `${a.repo.name} is waiting on you`,
129132 titleAttr: `${a.repo.ownerName}/${a.repo.name}: ${a.why ?? a.consequence.why}`,
130133 meta: html`${a.repo.ownerName}/${a.repo.name} · ${time(a.since)} · ${a.why ?? a.consequence.why} · ${advised}${pending}`,
134 ...(step === null ? {} : { after: stepLink(step) }),
131135 // The trail survives the meta line's ellipsis: the verdict and the size are the facts this row gives.
132136 ...(a.pull === undefined
133137 ? {}
test/pull-next-tui.test.ts +1 −1
@@ -71,7 +71,7 @@ test("the terminal's pull screen says who acts next, as the page does", async ()
7171 await Bun.sleep(20);
7272 }
7373 // The page's step line, too: this pull is at review.
74 if (!tui.screen.toText().includes("step: review")) throw new Error(tui.screen.toText());
74 if (!tui.screen.toText().includes("step: Review — ")) throw new Error(tui.screen.toText());
7575 } finally {
7676 tui.stop();
7777 }
test/pull-step-surfaces.test.ts added +145
@@ -0,0 +1,145 @@
1 /** The feed, the pulls list and /review each say which of check, review, land, live a pull is at (#7783); /review's is in review-page.test.ts. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { addToCrew, createAgent } from "../src/actions/crew.ts";
9 import { openPull } from "../src/actions/pulls.ts";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { readFeedPage } from "../src/feed/feed.ts";
12 import { stepOfStored } from "../src/flow/path.ts";
13 import type { Pull } from "../src/core/pulls.ts";
14 import type { Repo } from "../src/core/repos.ts";
15 import { createUser, getUserByName, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const PASSWORD = "a-long-step-passphrase";
22
23 let dataDir = "";
24 let app: App;
25 let servers: Servers;
26 let alice: User;
27 let cookie = "";
28 let repo: Repo;
29 let pull: Pull;
30
31 beforeAll(async () => {
32 dataDir = await mkdtemp(join(tmpdir(), "forge-step-surfaces-"));
33 app = createApp({ dataDir, port: 0 });
34 servers = await serve(app);
35 const a = await createUser(app.db, {
36 origin: "seed",
37 trusted: true,
38 username: "alice",
39 email: "[email protected]",
40 password: PASSWORD,
41 adminIfFirst: true,
42 });
43 if (!a.ok) throw new Error(a.error);
44 alice = a.value;
45 const res = await fetch(`${servers.url}/signin`, {
46 method: "POST",
47 body: new URLSearchParams({ username: "alice", password: PASSWORD }),
48 redirect: "manual",
49 });
50 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
51 await createAgent(app, alice, { username: "bot" });
52 const made = await createRepo(app, alice, { name: "site", visibility: "public" });
53 if (!made.ok) throw new Error(String(made.error));
54 repo = made.value;
55 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# site\n" }] });
56 addToCrew(app, alice, repo, "bot");
57 await commitToBranch(app, alice, repo, {
58 branch: "bot/1",
59 base: "main",
60 message: "Tidy",
61 files: [{ path: "F.md", content: "1\n" }],
62 });
63 const opened = await openPull(app, getUserByName(app.db, "bot")!, repo, {
64 title: "Tidy the footer",
65 body: "",
66 baseBranch: "main",
67 headBranch: "bot/1",
68 });
69 if (!opened.ok) throw new Error(String(opened.error));
70 pull = opened.value;
71 }, 30_000);
72
73 afterAll(async () => {
74 await servers.stop();
75 app.close();
76 await rm(dataDir, { recursive: true, force: true });
77 });
78
79 const squash = (s: string): string => s.replace(/\s+/g, " ");
80 const get = async (path: string): Promise<string> =>
81 squash(await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text());
82
83 /** The step word linked to /pipeline, then the sentence from `stepOfStored`. */
84 function expected(): string {
85 const step = stepOfStored(app.db, repo, pull)!;
86 expect(step.step).toBe("review");
87 return `data-step="review"><a href="/pipeline" title="${step.why}">Review</a> · ${step.why}`;
88 }
89
90 test("the feed's post for a pull says its step, linked to /pipeline", async () => {
91 const item = readFeedPage(app.db, alice).items.find((i) => i.link === `/alice/site/pulls/${pull.number}`);
92 expect(item?.step?.step).toBe("review");
93 expect(await get("/feed")).toContain(expected());
94 });
95
96 test("the repository's pulls list says each pull's step", async () => {
97 expect(await get("/alice/site/pulls")).toContain(expected());
98 });
99
100 const openMore = async (r: Repo, by: User, name: string): Promise<Pull> => {
101 await commitToBranch(app, alice, r, {
102 branch: `more/${name}`,
103 base: "main",
104 message: name,
105 files: [{ path: `${name}.md`, content: `${name}\n` }],
106 });
107 const opened = await openPull(app, by, r, { title: `Pull ${name}`, body: "", baseBranch: "main", headBranch: `more/${name}` });
108 if (!opened.ok) throw new Error(String(opened.error));
109 return opened.value;
110 };
111
112 test("a long list says every pull's step and stays quick", async () => {
113 for (let i = 0; i < 24; i++) await openMore(repo, alice, `long${i}`);
114 const started = performance.now();
115 const html = await get("/alice/site/pulls");
116 const took = performance.now() - started;
117 expect(html.match(/data-step="review"/g)?.length).toBeGreaterThanOrEqual(25);
118 expect(took).toBeLessThan(500);
119 });
120
121 test("a pull closed without landing has no step, on the feed or the list", async () => {
122 const gone = await openMore(repo, alice, "gone");
123 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(gone.id);
124 const item = readFeedPage(app.db, alice, { limit: 60 }).items.find((i) => i.link === `/alice/site/pulls/${gone.number}`);
125 expect(item?.step).toBeUndefined();
126 expect(await get(`/alice/site/pulls?state=closed`)).not.toContain('data-step=');
127 });
128
129 test("a post about a pull in a private repository carries a step for its readers only", async () => {
130 const bob = await createUser(app.db, {
131 origin: "seed",
132 trusted: true,
133 username: "bob",
134 email: "[email protected]",
135 password: PASSWORD,
136 });
137 if (!bob.ok) throw new Error(bob.error);
138 const secret = await createRepo(app, alice, { name: "vault", visibility: "private" });
139 if (!secret.ok) throw new Error(String(secret.error));
140 await commitToBranch(app, alice, secret.value, { message: "seed", files: [{ path: "README.md", content: "# v\n" }] });
141 const hidden = await openMore(secret.value, alice, "hidden");
142 const link = `/alice/vault/pulls/${hidden.number}`;
143 expect(readFeedPage(app.db, alice, { limit: 60 }).items.find((i) => i.link === link)?.step?.step).toBe("review");
144 expect(readFeedPage(app.db, bob.value, { limit: 60 }).items.some((i) => i.link === link)).toBe(false);
145 });
test/review-page.test.ts +9 −1
@@ -212,7 +212,7 @@ test("a pull with a look shows production beside its preview, outside the row's
212212 expect(stale.match(/data-slot="thumb"/g)?.length).toBe(2);
213213 expect(stale).toContain('alt="Production look, before"');
214214 expect(stale).toContain('aria-label="Preview look, after: opens the pull"');
215 expect(stale.match(/<a /g)?.length).toBe(2); // the preview link and the after thumb; the before one is no link
215 expect(stale.match(/<a /g)?.length).toBe(3); // the preview link, the after thumb and the step word; the before one is no link
216216 expect(stale).toContain("/alice/wait/looks/production/");
217217 expect(stale).toContain('width="30" height="40"');
218218 expect(stale).toContain('loading="lazy"');
@@ -261,3 +261,11 @@ test("a kept stat is the sentence in the row's trail, singular for one file", as
261261 expect(one).not.toContain("1 files");
262262 expect((await rowsOf()).find((r) => r.includes("Crew asked about this"))!).not.toContain("changed");
263263 });
264
265 test("each pull row says its step on the path, linked to /pipeline (#7783)", async () => {
266 const html = (await (await fetch(`${servers.url}/review`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
267 // A pull whose check was cancelled is at check, and says why.
268 expect(html).toContain(
269 'data-step="check"><a href="/pipeline" title="Its check was cancelled.">Check</a> · Its check was cancelled.',
270 );
271 });
test/tui-pull-step.test.ts added +91
@@ -0,0 +1,91 @@
1 /** The terminal's feed, pulls list and on-you list say a pull's step too (#7783). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { openPull } from "../src/actions/pulls.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App as Forge } from "../src/app.ts";
9 import type { Pull } from "../src/core/pulls.ts";
10 import type { Repo } from "../src/core/repos.ts";
11 import type { User } from "../src/core/users.ts";
12 import { stepLine, stepOfStored } from "../src/flow/path.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { dashboardScreen } from "../src/tui/screens/dashboard.ts";
15 import { feedScreen } from "../src/tui/screens/feed.ts";
16 import { issuesScreen } from "../src/tui/screens/issues.ts";
17 import { local } from "../src/tui/screens/where.ts";
18 import { headlessTerminal } from "../src/tui/term/terminal.ts";
19 import { seedAdmin } from "./helpers/admin.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22
23 quietLogs();
24
25 let dataDir: string;
26 let forge: Forge;
27 let alice: User;
28 let repo: Repo;
29 let one: Pull;
30
31 beforeAll(async () => {
32 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-step-"));
33 forge = createApp({ dataDir, port: 0 });
34 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
35 if (!a.ok) throw new Error("signup failed");
36 alice = a.value;
37 const r = await createRepo(forge, alice, { name: "steps", visibility: "public" });
38 if (!r.ok) throw new Error("repo creation failed");
39 repo = r.value;
40 await commitToBranch(forge, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# s\n" }] });
41 await commitToBranch(forge, alice, repo, {
42 branch: "feature/one",
43 base: "main",
44 message: "One",
45 files: [{ path: "one.ts", content: "export const one = 1;\n" }],
46 });
47 const opened = await openPull(forge, alice, repo, {
48 title: "Pull one",
49 body: "",
50 baseBranch: "main",
51 headBranch: "feature/one",
52 });
53 if (!opened.ok) throw new Error(String(opened.error));
54 one = opened.value;
55 forge.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(one.id, alice.id);
56 });
57
58 afterAll(async () => {
59 forge.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 async function screenText(make: (s: ReturnType<typeof createForgeApp>["session"], c: ReturnType<typeof createForgeApp>["app"]["ctx"]) => ReturnType<typeof feedScreen>): Promise<string> {
64 const { app, session } = createForgeApp(
65 forge,
66 { sshUsername: "alice", remoteAddress: "127.0.0.1", user: alice },
67 { terminal: headlessTerminal(140, 44), exitHooks: false },
68 );
69 app.start();
70 await settleApp(app);
71 app.push(make(session, app.ctx));
72 await settleApp(app);
73 const text = app.screen.toText();
74 app.stop();
75 return text;
76 }
77
78 const said = () => stepLine(stepOfStored(forge.db, repo, one)!);
79
80 test("the terminal's feed prints the step line under a pull's post", async () => {
81 expect(said()).toStartWith("Review — ");
82 expect(await screenText((s, c) => feedScreen(s, c))).toContain(said());
83 });
84
85 test("the terminal's pulls list prints the step line under each pull", async () => {
86 expect(await screenText((s, c) => issuesScreen(s, c, local(s, repo), "pull"))).toContain(said());
87 });
88
89 test("the terminal's on-you list prints the step line under a pull assigned to the viewer", async () => {
90 expect(await screenText((s, c) => dashboardScreen(s, c))).toContain(said());
91 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs