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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7783-the-feed-the-pulls-list-and-the-tui-say → main 3 commits fork 3 threads · 3 unresolved
19 files
changed +363
−13
README.md +1 −1
| @@ -399,7 +399,7 @@ work here. | ||
| 399 | 399 | |
| 400 | 400 | ## What works today |
| 401 | 401 | |
| 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). | |
| 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). | |
| 403 | 403 | |
| 404 | 404 | A public app of a public repository tells an agent that lands on it what it can |
| 405 | 405 | 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, | ||
| 34 | 34 | `Check · **Review** · Land · Live — No review yet.`, linked to `/pipeline`. |
| 35 | 35 | A finished path (live, or landed where nothing deploys) carries a "done" badge. |
| 36 | 36 | 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. | |
| 38 | 46 | `changeHead` computes "who acts next" once and hands it to `stepOfPull`. |
| 39 | 47 | |
| 40 | 48 | ## Not here yet |
| 41 | 49 | |
| 42 | 50 | The rest of #7703 is filed as its own issues: remove the proof (#7779), run |
| 43 | 51 | 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"; | ||
| 19 | 19 | import { Stamped } from "../kernel/stamped.ts"; |
| 20 | 20 | import { foldBursts } from "./fold.ts"; |
| 21 | 21 | import { rank, type FeedItem } from "./rank.ts"; |
| 22 | import { stepsOf } from "./step.ts"; | |
| 22 | 23 | import { settledOf } from "./settled.ts"; |
| 23 | 24 | |
| 24 | 25 | export const FEED_LIMIT_DEFAULT = 20; |
| @@ -200,9 +201,11 @@ export function readFeedPage( | ||
| 200 | 201 | const started = performance.now(); |
| 201 | 202 | const settled = settledOf(db, page); |
| 202 | 203 | options.phase?.("settled", performance.now() - started); |
| 204 | const steps = stepsOf(db, page); | |
| 203 | 205 | const items = page.map((i) => { |
| 204 | 206 | 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 }) }; | |
| 206 | 209 | }); |
| 207 | 210 | const last = items[items.length - 1]; |
| 208 | 211 | return { |
src/feed/rank.ts +3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { decay, type Affinity, type Weighted, type Why } from "./affinity.ts"; |
| 10 | 10 | import type { Candidate, FeedAsk, FeedLook } from "./items.ts"; |
| 11 | import type { PathStep } from "../flow/path.ts"; | |
| 11 | 12 | import type { Settled } from "./settled.ts"; |
| 12 | 13 | |
| 13 | 14 | export interface FeedItem { |
| @@ -32,6 +33,8 @@ export interface FeedItem { | ||
| 32 | 33 | readonly look?: FeedLook; |
| 33 | 34 | /** For a post about a pull or an issue that is over: how it ended. Absent while open. */ |
| 34 | 35 | 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; | |
| 35 | 38 | } |
| 36 | 39 | |
| 37 | 40 | 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) { | |
| 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 @@ | ||
| 13 | 13 | |
| 14 | 14 | import type { Database } from "bun:sqlite"; |
| 15 | 15 | import { getAppByRepo } from "../core/apps.ts"; |
| 16 | import type { Pull } from "../core/pulls.ts"; | |
| 16 | import { getPull, type Pull } from "../core/pulls.ts"; | |
| 17 | 17 | import type { Repo } from "../core/repos.ts"; |
| 18 | 18 | import { reviewSummary } from "../core/reviews.ts"; |
| 19 | 19 | import { checkSummary, type RunState } from "../core/runs.ts"; |
| @@ -125,3 +125,20 @@ export function stepOfPull( | ||
| 125 | 125 | next, |
| 126 | 126 | }); |
| 127 | 127 | } |
| 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 { | ||
| 53 | 53 | previewAsksBulk, |
| 54 | 54 | undoAsksBulk, |
| 55 | 55 | } from "../../actions/asks_bulk.ts"; |
| 56 | import { stepLine, stepOfNumber } from "../../flow/path.ts"; | |
| 56 | 57 | import { reviewSetOf, type ReviewSet } from "../../web/studio/review_set.ts"; |
| 57 | 58 | import { staleWords } from "../../flow/kpi_words.ts"; |
| 58 | 59 | import { askMeasureWeek, asksMeasureEmpty, asksMeasureWords } from "../../core/ask_ledger.ts"; |
| @@ -175,6 +176,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 175 | 176 | let pins: Pin[] = []; |
| 176 | 177 | let pinned = new Set<string>(); |
| 177 | 178 | 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>(); | |
| 178 | 181 | /** The week's asks line, read on reload; drawing only reads it. */ |
| 179 | 182 | let asksLine: string | undefined; |
| 180 | 183 | /** 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 { | ||
| 335 | 338 | pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(); |
| 336 | 339 | all = listReposVisibleTo(f.db, f.user, { limit: 200 }); |
| 337 | 340 | 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 | ); | |
| 338 | 347 | asksLine = undefined; |
| 339 | 348 | if (f.user !== undefined && f.user.kind === "user") { |
| 340 | 349 | const m = askMeasureWeek(f.db, f.user.id); |
| @@ -1316,6 +1325,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1316 | 1325 | : ISSUE_MARK; |
| 1317 | 1326 | return { |
| 1318 | 1327 | 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)! } : {}), | |
| 1319 | 1329 | meta: `${item.waiting.word} · on ${item.waiting.on}${item.passed === undefined ? "" : ` · handed to you: ${item.passed}`}${item.late?.advice ?? item.overAdvice ?? ""}`, |
| 1320 | 1330 | }; |
| 1321 | 1331 | }), |
src/tui/screens/feed.ts +2
| @@ -17,6 +17,7 @@ import type { Ctx, Screen } from "../app/app.ts"; | ||
| 17 | 17 | import { binding } from "../app/keys.ts"; |
| 18 | 18 | import type { Session } from "../session.ts"; |
| 19 | 19 | import { fill, len, split } from "../ui/layout.ts"; |
| 20 | import { stepLine } from "../../flow/path.ts"; | |
| 20 | 21 | import { emptyState } from "../ui/widgets.ts"; |
| 21 | 22 | import { ListState, ago, drawList, errorText, runAsync, type ListRow } from "./common.ts"; |
| 22 | 23 | import type { PaletteEntry } from "./entries.ts"; |
| @@ -86,6 +87,7 @@ export function feedScreen(f: Session, ctx: Ctx): Screen { | ||
| 86 | 87 | title: oneLine(`${item.actor ?? item.app} ${item.what}`), |
| 87 | 88 | secondary: item.app, |
| 88 | 89 | meta: ago(item.at), |
| 90 | ...(item.step === undefined ? {} : { note: stepLine(item.step) }), | |
| 89 | 91 | })); |
| 90 | 92 | drawList(s, list!, c, state, rows); |
| 91 | 93 | 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"; | ||
| 13 | 13 | import type { IssueKind, Label } from "../../core/issues.ts"; |
| 14 | 14 | import { getIssuesByNumbers, numberKey } from "../../core/issues.ts"; |
| 15 | 15 | import { LATER, pillarsOf, servedBy, type Pillar } from "../../core/pillars.ts"; |
| 16 | import { stepLine, stepOfNumber } from "../../flow/path.ts"; | |
| 16 | 17 | import { mergedPullNumbers } from "../../core/pulls.ts"; |
| 17 | 18 | import { repoDir } from "../../git/repos.ts"; |
| 18 | 19 | import type { Repo } from "../../core/repos.ts"; |
| @@ -93,6 +94,8 @@ export function issuesScreen( | ||
| 93 | 94 | * set: one grouped query for the whole page, never one per row. |
| 94 | 95 | */ |
| 95 | 96 | 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>(); | |
| 96 | 99 | /** |
| 97 | 100 | * The pillars of this repository and the one each row's issue serves, |
| 98 | 101 | * read once per reload: `servedBy` answers for the whole repository in two |
| @@ -147,6 +150,11 @@ export function issuesScreen( | ||
| 147 | 150 | numbers, |
| 148 | 151 | )) |
| 149 | 152 | 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 | } | |
| 150 | 158 | ctx.invalidate(); |
| 151 | 159 | }; |
| 152 | 160 | |
| @@ -174,6 +182,7 @@ export function issuesScreen( | ||
| 174 | 182 | const reload = async (): Promise<void> => { |
| 175 | 183 | merged.clear(); |
| 176 | 184 | verdicts.clear(); |
| 185 | steps.clear(); | |
| 177 | 186 | tags.clear(); |
| 178 | 187 | items = await take(); |
| 179 | 188 | loaded = true; |
| @@ -345,6 +354,7 @@ export function issuesScreen( | ||
| 345 | 354 | ? ("open" as const) |
| 346 | 355 | : ("closed" as const), |
| 347 | 356 | secondary: item.labels.map((l) => l.name).join(" "), |
| 357 | ...(steps.has(item.number) ? { note: steps.get(item.number)! } : {}), | |
| 348 | 358 | // An imported row is the origin's author, marked as upstream: a |
| 349 | 359 | // login from another forge must never read as an account here. |
| 350 | 360 | meta: metaOf( |
src/tui/screens/pull.ts +2 −2
| @@ -63,7 +63,7 @@ import { checkWords } from "../../checks/summary.ts"; | ||
| 63 | 63 | import { oneLine } from "../../checks/words.ts"; |
| 64 | 64 | import { resolveOid } from "../../git/merge.ts"; |
| 65 | 65 | 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"; | |
| 67 | 67 | import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts"; |
| 68 | 68 | import { voteVariant } from "../../actions/variants.ts"; |
| 69 | 69 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| @@ -1018,7 +1018,7 @@ export function pullScreen( | ||
| 1018 | 1018 | x1, |
| 1019 | 1019 | head!.y + 1, |
| 1020 | 1020 | 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}`}`, | |
| 1022 | 1022 | Math.max(0, edge - x1), |
| 1023 | 1023 | ), |
| 1024 | 1024 | style(c.mutedFg, c.bg), |
src/web/studio/change.ts +2 −2
| @@ -85,7 +85,7 @@ import { visionPreviewCard } from "./direction.ts"; | ||
| 85 | 85 | import { holdCard } from "./hold.ts"; |
| 86 | 86 | import { pullMayRun } from "../../core/pull_trust.ts"; |
| 87 | 87 | 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"; | |
| 89 | 89 | import { changeHref } from "./nouns.ts"; |
| 90 | 90 | import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts"; |
| 91 | 91 | |
| @@ -253,7 +253,7 @@ export function changeHead( | ||
| 253 | 253 | export function pathLine(p: PathStep): Html { |
| 254 | 254 | const at = STEPS.indexOf(p.step); |
| 255 | 255 | const words = STEPS.map((s, i) => { |
| 256 | const label = `${s.charAt(0).toUpperCase()}${s.slice(1)}`; | |
| 256 | const label = stepWord(s); | |
| 257 | 257 | if (i === at) |
| 258 | 258 | return html`<strong aria-current="step" title="${STEP_MEANS[s]}">${label}</strong>${p.done ? html` ${badge("done", { tone: "success" })}` : null}`; |
| 259 | 259 | return html`<span title="${STEP_MEANS[s]}">${label}</span>`; |
src/web/studio/feed.ts +2
| @@ -29,6 +29,7 @@ import { fragment } from "../layout.ts"; | ||
| 29 | 29 | import { draftJs } from "../draft.ts"; |
| 30 | 30 | import { outboxJs } from "../outbox.ts"; |
| 31 | 31 | import { avatar, btn, card, disclosure, empty, nameMark, row, stateBadge, time } from "../ui.ts"; |
| 32 | import { stepLink } from "./step_link.ts"; | |
| 32 | 33 | import { writableAppsOf } from "./asks.ts"; |
| 33 | 34 | |
| 34 | 35 | /** Events that can change what the feed says. */ |
| @@ -134,6 +135,7 @@ export function feedCard( | ||
| 134 | 135 | : html`<a class="look" data-slot="feed" href="${item.link}" tabindex="-1" aria-hidden="true" |
| 135 | 136 | ><img src="${picture.src}" width="${picture.width}" height="${picture.height}" alt="" loading="lazy" decoding="async" |
| 136 | 137 | /></a>`} |
| 138 | ${item.step === undefined ? null : html`<p class="break">${stepLink(item.step)}</p>`} | |
| 137 | 139 | ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}`; |
| 138 | 140 | const foot = disclosure( |
| 139 | 141 | `${reply ? "Reply or " : ""}${target ? "Steer" : "Tell"} · more`, |
src/web/studio/lists.ts +11 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * `/pulls`. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import type { Database } from "bun:sqlite"; | |
| 12 | 13 | import { sealedScope } from "../../core/sealed.ts"; |
| 13 | 14 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 14 | 15 | import { getAppByRepo } from "../../core/apps.ts"; |
| @@ -19,10 +20,12 @@ import { | ||
| 19 | 20 | type Issue, |
| 20 | 21 | type Label, |
| 21 | 22 | } 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"; | |
| 23 | 25 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; |
| 24 | 26 | import { FLOW_LABEL } from "../../core/flow_label.ts"; |
| 25 | 27 | import { flowButton } from "../flow_button.ts"; |
| 28 | import { stepLink } from "./step_link.ts"; | |
| 26 | 29 | import { waitingCount } from "../../actions/reports.ts"; |
| 27 | 30 | |
| 28 | 31 | import { listRepoRuns } from "../../actions/runs.ts"; |
| @@ -486,6 +489,12 @@ export async function tellsList(ctx: Ctx): Promise<Response> { | ||
| 486 | 489 | return response; |
| 487 | 490 | } |
| 488 | 491 | |
| 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 | ||
| 489 | 498 | /** |
| 490 | 499 | * GET /:owner/:name/pulls: every pull request, for every |
| 491 | 500 | * 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> { | ||
| 552 | 561 | : pull.authorName} · ${info.note} · |
| 553 | 562 | ${when(answered.get(pull.number), (t) => html`${tellLine(base, t)} · `)}${time(pull.updatedAt)}`, |
| 554 | 563 | trail: wordBadge(info.word), |
| 564 | ...stepAfter(db, repo, pull), | |
| 555 | 565 | }), |
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. |
||
| 556 | 566 | ), |
| 557 | 567 | ); |
src/web/studio/review_page.ts +4
| @@ -18,6 +18,8 @@ import { html, type Html } from "../html.ts"; | ||
| 18 | 18 | import { page } from "../layout.ts"; |
| 19 | 19 | import { badge, empty, listCard, row, runStateBadge, time } from "../ui.ts"; |
| 20 | 20 | import { lookThumb, lookThumbPlain, thumbLook } from "../pages/looks.ts"; |
| 21 | import { stepOfStored } from "../../flow/path.ts"; | |
| 22 | import { stepLink } from "./step_link.ts"; | |
| 21 | 23 | import { actRow, rowId } from "./review_act.ts"; |
| 22 | 24 | import { reviewSet } from "./review_set.ts"; |
| 23 | 25 | import { staleArchive } from "./review_stale.ts"; |
| @@ -120,6 +122,7 @@ export function reviewPage(ctx: Ctx): Response { | ||
| 120 | 122 | ? " · Diff size unavailable — open the pull to see it" |
| 121 | 123 | : ""; |
| 122 | 124 | const act = actRow(a); |
| 125 | const step = a.pull === undefined ? null : stepOfStored(ctx.app.db, a.repo, a.pull); | |
| 123 | 126 | return row({ |
| 124 | 127 | href, |
| 125 | 128 | nav: true, |
| @@ -128,6 +131,7 @@ export function reviewPage(ctx: Ctx): Response { | ||
| 128 | 131 | title: a.pull?.title ?? a.issue?.title ?? a.proposal?.title ?? `${a.repo.name} is waiting on you`, |
| 129 | 132 | titleAttr: `${a.repo.ownerName}/${a.repo.name}: ${a.why ?? a.consequence.why}`, |
| 130 | 133 | meta: html`${a.repo.ownerName}/${a.repo.name} · ${time(a.since)} · ${a.why ?? a.consequence.why} · ${advised}${pending}`, |
| 134 | ...(step === null ? {} : { after: stepLink(step) }), | |
| 131 | 135 | // The trail survives the meta line's ellipsis: the verdict and the size are the facts this row gives. |
| 132 | 136 | ...(a.pull === undefined |
| 133 | 137 | ? {} |
src/web/studio/step_link.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | /** | |
| 2 | * A pull's step on the one path (#7703) as a row or a post wears it: the | |
| 3 | * word, linked to /pipeline, and the sentence beside it. The words come | |
| 4 | * from `src/flow/path.ts`; this only draws them. | |
| 5 | */ | |
| 6 | ||
| 7 | import { stepWord, type PathStep } from "../../flow/path.ts"; | |
| 8 | import { html, type Html } from "../html.ts"; | |
| 9 | ||
| 10 | export function stepLink(p: Pick<PathStep, "step" | "why">): Html { | |
| 11 | return html`<span class="small muted" data-step="${p.step}"><a href="/pipeline" title="${p.why}">${stepWord(p.step)}</a> · ${p.why}</span>`; | |
| 12 | } |
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 () | ||
| 71 | 71 | await Bun.sleep(20); |
| 72 | 72 | } |
| 73 | 73 | // 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()); | |
| 75 | 75 | } finally { |
| 76 | 76 | tui.stop(); |
| 77 | 77 | } |
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 | ||
| 212 | 212 | expect(stale.match(/data-slot="thumb"/g)?.length).toBe(2); |
| 213 | 213 | expect(stale).toContain('alt="Production look, before"'); |
| 214 | 214 | 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 | |
| 216 | 216 | expect(stale).toContain("/alice/wait/looks/production/"); |
| 217 | 217 | expect(stale).toContain('width="30" height="40"'); |
| 218 | 218 | 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 | ||
| 261 | 261 | expect(one).not.toContain("1 files"); |
| 262 | 262 | expect((await rowsOf()).find((r) => r.includes("Crew asked about this"))!).not.toContain("changed"); |
| 263 | 263 | }); |
| 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 | }); |
new above
offline
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.