Public: anyone can read this; the crew can change it.
Every open pull's page says who acts next and why, in one line under its title #6369
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6350-every-open-pull-s-page-says-who-acts-nex → main 3 commits fork 3 threads · 3 unresolved
10 files
changed +299
−6
docs/MCP.md +1 −1
| @@ -102,7 +102,7 @@ for a tunnel and a forge that sleeps. | ||
| 102 | 102 | | `prompts` | `{ repo, action, kind?, token?, at?, id? }`: list, add, run or remove an app's prompt sources (Instagram), as the `…/prompts` API does; the token goes in once and is never shown; listed only for an admin-scope token, and only the repository's admin may use it | yes | |
| 103 | 103 | | `proposal_tokens` | `{ repo, verb, child?, origin?, childRepo?, id? }`: give a child forge a propose-only credential: `mint` one (`child`, `origin`), `list` them, or `revoke` one by `id`; writers only | yes | |
| 104 | 104 | | `propose_to_parent_forge` | `{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull | yes | |
| 105 | | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | | | |
| 105 | | `pull` | `{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment | | | |
| 106 | 106 | | `pulls` | `{ repo, state?, limit? }`: a repository's pulls, newest first, one brief line each: `number`, `title`, `author`, `head`, `check` (state), `approvals`, `changesRequested`, `mergeable` (null once closed); `state` open (default) or closed, `limit` 1-50 (default 20); no bodies, no diffs | | |
| 107 | 107 | | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents | | |
| 108 | 108 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | | |
src/flow/next_actor.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * Who acts next on an open pull, and why, in one line (#6350). `nextActor` | |
| 3 | * is plain data in, plain data out; `nextOfPull` reads the data for one pull | |
| 4 | * and the pull page and the MCP `pull` both answer from it. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { getHold } from "../core/holds.ts"; | |
| 9 | import type { Pull } from "../core/pulls.ts"; | |
| 10 | import { checkSummary, type RunState } from "../core/runs.ts"; | |
| 11 | import { reviewSummary } from "../core/reviews.ts"; | |
| 12 | ||
| 13 | export type Actor = "builder" | "reviewer" | "queue" | "person"; | |
| 14 | ||
| 15 | export interface Next { | |
| 16 | readonly who: Actor; | |
| 17 | readonly why: string; | |
| 18 | } | |
| 19 | ||
| 20 | export interface NextInput { | |
| 21 | /** Who holds the pull and why; a hold keeps every rule off it. */ | |
| 22 | readonly held: { readonly by: string; readonly why: string } | null; | |
| 23 | readonly conflicted: boolean; | |
| 24 | readonly check: RunState | "none"; | |
| 25 | /** Reviewers whose current verdict, on this head, asks for changes / approves. */ | |
| 26 | readonly changesRequested: number; | |
| 27 | readonly approvals: number; | |
| 28 | /** Verdicts passed on an older head: the pull was pushed to since. */ | |
| 29 | readonly pushedSince: number; | |
| 30 | } | |
| 31 | ||
| 32 | const WAITING = { | |
| 33 | none: "waiting for its check", | |
| 34 | queued: "its check is queued", | |
| 35 | running: "its check is running", | |
| 36 | cancelled: "its check was cancelled", | |
| 37 | } as const; | |
| 38 | ||
| 39 | export function nextActor(i: NextInput): Next { | |
| 40 | if (i.held !== null) | |
| 41 | return { who: "person", why: `${i.held.by} holds it: ${i.held.why}` }; | |
| 42 | if (i.conflicted) return { who: "builder", why: "it conflicts with its base" }; | |
| 43 | if (i.changesRequested > 0) | |
outdated
|
||
| 44 | return { who: "builder", why: "changes were asked on this head" }; | |
| 45 | if (i.check === "failed") return { who: "builder", why: "its check is red" }; | |
| 46 | if (i.approvals > 0) { | |
| 47 | if (i.check === "ok") return { who: "queue", why: "approved and green" }; | |
| 48 | return { who: "queue", why: `approved, ${WAITING[i.check]}` }; | |
| 49 | } | |
| 50 | return { | |
| 51 | who: "reviewer", | |
| 52 | why: i.pushedSince > 0 ? "pushed since the last review" : "no review yet", | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 56 | /** The next actor of an open pull at `headOid`; null once it is merged or closed. */ | |
| 57 | export function nextOfPull( | |
| 58 | db: Database, | |
| 59 | pull: Pull, | |
| 60 | live: { readonly headOid: string; readonly conflicts: number }, | |
| 61 | ): Next | null { | |
| 62 | if (pull.state !== "open" || pull.mergedAt !== null) return null; | |
| 63 | const reviews = reviewSummary(db, pull.id, live.headOid); | |
| 64 | const hold = getHold(db, pull.id); | |
| 65 | return nextActor({ | |
| 66 | held: hold === undefined ? null : { by: hold.heldByName, why: hold.why }, | |
| 67 | conflicted: live.conflicts > 0, | |
| 68 | check: checkSummary(db, pull.id, live.headOid).state, | |
| 69 | changesRequested: reviews.changesRequested, | |
| 70 | approvals: reviews.approvals, | |
| 71 | pushedSince: reviews.stale, | |
| 72 | }); | |
| 73 | } |
src/http/mcp/tools/pull.ts +6 −1
| @@ -5,12 +5,13 @@ import { getPull, pullDir, pullMergeState } from "../../../core/pulls.ts"; | ||
| 5 | 5 | import { previewEnv } from "../../../apps/data.ts"; |
| 6 | 6 | import { checkStale } from "../../../checks/stale.ts"; |
| 7 | 7 | import { checkSummary } from "../../../core/runs.ts"; |
| 8 | import { nextOfPull } from "../../../flow/next_actor.ts"; | |
| 8 | 9 | import { appView } from "../../../actions/apps.ts"; |
| 9 | 10 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 10 | 11 | |
| 11 | 12 | export const tool: Tool = { |
| 12 | 13 | name: "pull", |
| 13 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", | |
| 14 | doc: "`{ repo, number }`: a pull's title, state, head and base, and for an open pull whether it merges into the base's current head (`mergeable`) and the paths that stop it (`conflicts`), and `next: { who, why }` (open only; who acts next: builder, reviewer, queue or person), and `check: { state, baseOid }`: the latest check's state and the base tip it was merged with (empty when it merged nothing); `mergeable` and `conflicts` are the same fields as `GET …/pulls/:number`, `check` is `state` and `run.baseOid` from `GET …/pulls/:number/checks`; draws the `ui://forge/pull` card: `#N title`, state with check and conflicts while open, `head → base` linked to the pull, and the preview link when the pull has an environment", | |
| 14 | 15 | description: |
| 15 | 16 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `proposal`, `checkStale`, `check`.", |
| 16 | 17 | inputSchema: { |
| @@ -48,6 +49,10 @@ export const tool: Tool = { | ||
| 48 | 49 | proposal: pullProposalOf(ctx.app.db, pull.id) ?? null, |
| 49 | 50 | check: { state: check.state, baseOid: check.run?.baseOid ?? "" }, |
| 50 | 51 | checkStale: stale, |
| 52 | next: nextOfPull(ctx.app.db, pull, { | |
| 53 | headOid: pull.headOid, | |
| 54 | conflicts: state?.conflicts?.length ?? 0, | |
| 55 | }), | |
| 51 | 56 | ...(state ?? {}), |
| 52 | 57 | }); |
| 53 | 58 | // What the card needs and the text does not say: where the pull lives |
src/tui/screens/pull.ts +11 −1
| @@ -56,6 +56,7 @@ import { checkStale, checksHold } from "../../checks/stale.ts"; | ||
| 56 | 56 | import { checkWords } from "../../checks/summary.ts"; |
| 57 | 57 | import { oneLine } from "../../checks/words.ts"; |
| 58 | 58 | import { resolveOid } from "../../git/merge.ts"; |
| 59 | import { nextOfPull, type Next } from "../../flow/next_actor.ts"; | |
| 59 | 60 | import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts"; |
| 60 | 61 | import { voteVariant } from "../../actions/variants.ts"; |
| 61 | 62 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| @@ -215,6 +216,8 @@ export function pullScreen( | ||
| 215 | 216 | let diff: Diff = { files: [], additions: 0, deletions: 0 }; |
| 216 | 217 | let reviews: AnchoredReview[] = []; |
| 217 | 218 | let threads: Thread[] = []; |
| 219 | /** Who acts next, for a local open pull; the page says the same line. */ | |
| 220 | let next: Next | null = null; | |
| 218 | 221 | /** Where each team the issue names stands; empty when it names none. */ |
| 219 | 222 | let teams: TeamVerdict[] = []; |
| 220 | 223 | /** Settled threads the reader has opened back up, for this visit only. */ |
| @@ -300,6 +303,13 @@ export function pullScreen( | ||
| 300 | 303 | ? NO_CHECK |
| 301 | 304 | : checkSummary(f.db, row.id, row.headOid || (facts?.headOid ?? "")); |
| 302 | 305 | checkBase = row?.baseBranch ?? facts?.baseBranch ?? ""; |
| 306 | next = | |
| 307 | row === undefined | |
| 308 | ? null | |
| 309 | : nextOfPull(f.db, row, { | |
| 310 | headOid: row.headOid || (facts?.headOid ?? ""), | |
| 311 | conflicts: facts?.conflicts.length ?? 0, | |
| 312 | }); | |
| 303 | 313 | checkProgress = runningProgress(f.app.config.dataDir, check); |
| 304 | 314 | const spec = |
| 305 | 315 | repo === undefined || !isApp |
| @@ -869,7 +879,7 @@ export function pullScreen( | ||
| 869 | 879 | x1, |
| 870 | 880 | head!.y + 1, |
| 871 | 881 | truncate( |
| 872 | ` ${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)}` : ""}`, | |
| 882 | ` ${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)}` : ""}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`, | |
| 873 | 883 | Math.max(0, edge - x1), |
| 874 | 884 | ), |
| 875 | 885 | style(c.mutedFg, c.bg), |
src/web/pages/pull.ts +3 −1
| @@ -104,6 +104,7 @@ import { previewEnv } from "../../apps/data.ts"; | ||
| 104 | 104 | import { specFindings } from "./pull-spec.ts"; |
| 105 | 105 | import { pullPreviewFindings } from "../../core/preview_findings.ts"; |
| 106 | 106 | import { crewActivityCard } from "./runs.ts"; |
| 107 | import { nextOfPull } from "../../flow/next_actor.ts"; | |
| 107 | 108 | import { holdCard } from "../studio/hold.ts"; |
| 108 | 109 | import { |
| 109 | 110 | changeHead, |
| @@ -949,7 +950,8 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 949 | 950 | compact: true, |
| 950 | 951 | ownTitle: true, |
| 951 | 952 | })} |
| 952 | ${changeHead(ctx, repo, pull)} ${changeTabs(repo, pull, "files")} | |
| 953 | ${changeHead(ctx, repo, pull, nextOfPull(ctx.app.db, pull, { headOid: status.headOid, conflicts: status.conflicts.length }))} | |
| 954 | ${changeTabs(repo, pull, "files")} | |
| 953 | 955 | <p class="files-where muted small" id="files"> |
| 954 | 956 | <span class="mono">${headLabel} → ${pull.baseBranch}</span> |
| 955 | 957 | <span class="sep" aria-hidden="true">·</span> |
src/web/studio/change.ts +8 −2
| @@ -88,6 +88,7 @@ import { previewVision } from "../../crew/vision.ts"; | ||
| 88 | 88 | import { visionPreviewCard } from "./direction.ts"; |
| 89 | 89 | import { holdCard } from "./hold.ts"; |
| 90 | 90 | import { pullMayRun } from "../../core/pull_trust.ts"; |
| 91 | import { nextOfPull, type Next } from "../../flow/next_actor.ts"; | |
| 91 | 92 | import { changeHref } from "./nouns.ts"; |
| 92 | 93 | import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts"; |
| 93 | 94 | |
| @@ -191,7 +192,7 @@ function reviewEntry( | ||
| 191 | 192 | /** A change's head, the same on both of its tabs: the title, its state, |
| 192 | 193 | * who opened it and in answer to whom, where it came from, and what the |
| 193 | 194 | * crew spent on it. */ |
| 194 | export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html { | |
| 195 | export function changeHead(ctx: Ctx, repo: Repo, pull: Pull, next: Next | null = null): Html { | |
| 195 | 196 | const who = toldBy(ctx.app.db, pull); |
| 196 | 197 | const marks = marksByName(ctx.app.db, [pull.authorName, who ?? ""]); |
| 197 | 198 | return html`<div class="page-head"> |
| @@ -229,6 +230,10 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 229 | 230 | >`, |
| 230 | 231 | )} |
| 231 | 232 | </div> |
| 233 | ${when( | |
No test draws this line. Add a page test: an open pull shows |
||
| 234 | next, | |
| 235 | (n) => html`<p class="next-actor muted small">Next: ${n.who}, ${n.why}.</p>`, | |
| 236 | )} | |
| 232 | 237 | </div>`; |
| 233 | 238 | } |
| 234 | 239 | |
| @@ -597,7 +602,8 @@ async function changeConversation( | ||
| 597 | 602 | compact: true, |
| 598 | 603 | ownTitle: true, |
| 599 | 604 | })} |
| 600 | ${changeHead(ctx, repo, pull)} ${changeTabs(repo, pull, "change")} | |
| 605 | ${changeHead(ctx, repo, pull, status === undefined ? null : nextOfPull(db, pull, { headOid, conflicts: status.conflicts.length }))} | |
| 606 | ${changeTabs(repo, pull, "change")} | |
| 601 | 607 | ${when(siblings.length > 0, () => |
| 602 | 608 | html`<p class="muted small variant-link"> |
| 603 | 609 | <a href="${base}/variants/${pull.variantGroup ?? ""}" |
test/mcp.test.ts +1
| @@ -1231,6 +1231,7 @@ describe("the forge over MCP", () => { | ||
| 1231 | 1231 | expect(card.rows.some((r: any) => r.label === "Preview")).toBe(false); |
| 1232 | 1232 | const text = JSON.parse(body.result.content[0].text); |
| 1233 | 1233 | expect(text.title).toBe("Card wire"); |
| 1234 | expect(text.next).toEqual({ who: "reviewer", why: "no review yet" }); | |
| 1234 | 1235 | expect("card" in text).toBe(false); |
| 1235 | 1236 | }); |
| 1236 | 1237 |
test/next_actor.test.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { nextActor, type NextInput } from "../src/flow/next_actor.ts"; | |
| 3 | ||
| 4 | const base: NextInput = { | |
| 5 | held: null, | |
| 6 | conflicted: false, | |
| 7 | check: "none", | |
| 8 | changesRequested: 0, | |
| 9 | approvals: 0, | |
| 10 | pushedSince: 0, | |
| 11 | }; | |
| 12 | ||
| 13 | const cases: [string, Partial<NextInput>, string, string][] = [ | |
| 14 | ["held", { held: { by: "sam", why: "wait for legal" }, approvals: 1, check: "ok" }, "person", "sam holds it: wait for legal"], | |
| 15 | ["conflicted", { conflicted: true, approvals: 1, check: "ok" }, "builder", "it conflicts with its base"], | |
| 16 | ["changes asked", { changesRequested: 1, check: "ok" }, "builder", "changes were asked on this head"], | |
| 17 | ["red", { check: "failed", approvals: 1 }, "builder", "its check is red"], | |
| 18 | ["pushed since review", { pushedSince: 1, check: "ok" }, "reviewer", "pushed since the last review"], | |
| 19 | ["no review", { check: "running" }, "reviewer", "no review yet"], | |
| 20 | ["approved and green", { approvals: 1, check: "ok" }, "queue", "approved and green"], | |
| 21 | ["approved, no check", { approvals: 1 }, "queue", "approved, waiting for its check"], | |
| 22 | ["approved, queued", { approvals: 1, check: "queued" }, "queue", "approved, its check is queued"], | |
| 23 | ["approved, cancelled", { approvals: 1, check: "cancelled" }, "queue", "approved, its check was cancelled"], | |
| 24 | ["approved, running", { approvals: 1, check: "running" }, "queue", "approved, its check is running"], | |
| 25 | ]; | |
| 26 | ||
| 27 | describe("nextActor", () => { | |
| 28 | for (const [name, input, who, why] of cases) | |
| 29 | test(name, () => expect(nextActor({ ...base, ...input })).toEqual({ who: who as never, why })); | |
| 30 | }); |
test/pull-next-line.test.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** The pull page says who acts next under its title, on both tabs, for an open pull only. */ | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let alice: User; | |
| 20 | const numbers: Record<string, number> = {}; | |
| 21 | const cells = process.env["FORGE_CELLS"]; | |
| 22 | ||
| 23 | const read = async (repo: string, tab: "" | "/files"): Promise<string> => | |
| 24 | ( | |
| 25 | await fetch(`${servers.url}/alice/${repo}/pulls/${numbers[repo]}${tab}`, { | |
| 26 | headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` }, | |
| 27 | }) | |
| 28 | ).text(); | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | process.env["FORGE_CELLS"] = "off"; | |
| 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-next-line-")); | |
| 33 | app = createApp({ dataDir, port: 0 }); | |
| 34 | servers = await serve(app); | |
| 35 | const made = await createUser(app.db, { | |
| 36 | origin: "seed", | |
| 37 | trusted: true, | |
| 38 | username: "alice", | |
| 39 | email: "[email protected]", | |
| 40 | password: "a-good-passphrase", | |
| 41 | adminIfFirst: true, | |
| 42 | }); | |
| 43 | if (!made.ok) throw new Error(String(made.error)); | |
| 44 | alice = made.value; | |
| 45 | for (const name of ["open", "merged"]) { | |
| 46 | const repo = await createRepo(app, alice, { name, visibility: "public" }); | |
| 47 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 48 | for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) { | |
| 49 | const done = await commitToBranch(app, alice, repo.value, { | |
| 50 | ...(branch === undefined ? {} : { branch }), | |
| 51 | message: "change", | |
| 52 | files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }], | |
| 53 | }); | |
| 54 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 55 | } | |
| 56 | const pull = await openPull(app, alice, repo.value, { | |
| 57 | title: name, | |
| 58 | body: "", | |
| 59 | baseBranch: "main", | |
| 60 | headBranch: "topic", | |
| 61 | }); | |
| 62 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); | |
| 63 | numbers[name] = pull.value.number; | |
| 64 | if (name === "merged") { | |
| 65 | const done = await mergePull(app, alice, repo.value, pull.value, "merge"); | |
| 66 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 67 | } | |
| 68 | } | |
| 69 | }); | |
| 70 | ||
| 71 | afterAll(async () => { | |
| 72 | await servers.stop(); | |
| 73 | app.close(); | |
| 74 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 75 | else process.env["FORGE_CELLS"] = cells; | |
| 76 | await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("an open pull with no review names the reviewer on the change tab and on Files", async () => { | |
| 80 | for (const tab of ["", "/files"] as const) { | |
| 81 | const body = await read("open", tab); | |
| 82 | expect(body).toContain("Next: reviewer, no review yet."); | |
| 83 | expect(body).toContain('class="next-actor'); | |
| 84 | } | |
| 85 | }); | |
| 86 | ||
| 87 | test("a merged pull draws no next line", async () => { | |
| 88 | for (const tab of ["", "/files"] as const) | |
| 89 | expect(await read("merged", tab)).not.toContain("next-actor"); | |
| 90 | }); |
test/pull-next-tui.test.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | import { afterAll, beforeAll, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { openPull } from "../src/actions/pulls.ts"; | |
| 6 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 10 | import { pullScreen } from "../src/tui/screens/pull.ts"; | |
| 11 | import { local } from "../src/tui/screens/where.ts"; | |
| 12 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | import { settleApp } from "./helpers/tui.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let alice: User; | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dataDir = await mkdtemp(join(tmpdir(), "forge-next-tui-")); | |
| 24 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 25 | const made = await createUser(app.db, { | |
| 26 | origin: "seed", | |
| 27 | trusted: true, | |
| 28 | username: "alice", | |
| 29 | email: "[email protected]", | |
| 30 | password: "a-good-passphrase", | |
| 31 | adminIfFirst: true, | |
| 32 | }); | |
| 33 | if (!made.ok) throw new Error(String(made.error)); | |
| 34 | alice = made.value; | |
| 35 | }, 60_000); | |
| 36 | ||
| 37 | afterAll(async () => { | |
| 38 | app.close(); | |
| 39 | await rm(dataDir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | test("the terminal's pull screen says who acts next, as the page does", async () => { | |
| 43 | const repo = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 44 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 45 | for (const branch of ["main", "topic"]) { | |
| 46 | const done = await commitToBranch(app, alice, repo.value, { | |
| 47 | branch, | |
| 48 | message: "edit", | |
| 49 | files: [{ path: `${branch}.txt`, content: "x\n" }], | |
| 50 | }); | |
| 51 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 52 | } | |
| 53 | const opened = await openPull(app, alice, repo.value, { | |
| 54 | title: "Topic", | |
| 55 | baseBranch: "main", | |
| 56 | headBranch: "topic", | |
| 57 | }); | |
| 58 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 59 | const { app: tui, session } = createForgeApp( | |
| 60 | app, | |
| 61 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:alice", user: alice }, | |
| 62 | { terminal: headlessTerminal(140, 44), exitHooks: false }, | |
| 63 | ); | |
| 64 | tui.start(); | |
| 65 | try { | |
| 66 | tui.ctx.push(pullScreen(session, tui.ctx, local(session, repo.value), opened.value.number)); | |
| 67 | const end = Date.now() + 20_000; | |
| 68 | while (!tui.screen.toText().includes("next: reviewer, no review yet")) { | |
| 69 | if (Date.now() > end) throw new Error(tui.screen.toText()); | |
| 70 | await settleApp(tui); | |
| 71 | await Bun.sleep(20); | |
| 72 | } | |
| 73 | } finally { | |
| 74 | tui.stop(); | |
| 75 | } | |
| 76 | }); |
new above
offline
Non-blocking: for a repository whose forge.json declares no check,
noneis permanent, so "waiting for its check" overstates the wait. Worth a follow-up when the check spec is at hand.