Public: anyone can read this; the crew can change it.
One path a person can follow: check, review, land, live #7793
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7703-one-path-a-person-can-follow-check-revie → main 7 commits fork
17 files
changed +555
−10
README.md +2
| @@ -399,6 +399,8 @@ 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). | |
| 403 | ||
| 402 | 404 | A public app of a public repository tells an agent that lands on it what it can |
| 403 | 405 | do: its HTML pages, and the "not running" page, carry two `Link` headers — |
| 404 | 406 | `rel="help"` to `/__forge/report` and `rel="alternate"` to the forge's |
docs/MCP.md +1 −1
| @@ -136,7 +136,7 @@ for a tunnel and a forge that sleeps. | ||
| 136 | 136 | | `prompts` | (admin scope) `{ 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 | |
| 137 | 137 | | `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 | |
| 138 | 138 | | `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 | |
| 139 | | `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 `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), 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 | | | |
| 139 | | `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), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), 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 | | | |
| 140 | 140 | | `pulls` | `{ repo?, state?, limit?, cursor? }`: without `repo`, every open pull you may read, oldest first, as `{ pulls, next_cursor }` (limit 1-200, default 50): `repo`, `number`, `title`, `check`, `verdict` (approved, changes_requested or none) and `gate` (what holds it, or null; the `why` module's cause); with `repo`, 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 | | |
| 141 | 141 | | `queue_gates` | `{ repo, limit? }`: the merge queue's recent gates, newest first (`limit` 1–100, default 20), as `GET …/queue/gates` answers: each gate's pulls, state, reason, counts and failing tests by name with their first error lines, a test red in two gates that share no pull flagged `flaky` | | |
| 142 | 142 | | `quotas` | `{ key?, value?, dryRun? }` (forge admin): the per-account quotas `repositories`, `apps`, `storage` (MB), `previews`, `runs`, `checks`, each a number or null for none. `repositories` bounds repos on create, fork and copy; `apps` the apps made; `storage` the MB of repos plus app data, asked on push, create and fork; `previews` live previews; `runs` concurrent agent runs; `checks` concurrent checks. Each is asked before the thing is made or started and a forge admin is never limited; with `key` and `value` (`""` clears), planned first (`dryRun` is true unless `false`); applying needs an admin-scope token | yes | |
docs/design/areas/lists.md +2
| @@ -16,3 +16,5 @@ trails a palette row. | ||
| 16 | 16 | minimum height, so it is a touch target on a 360px phone. Styled in `src/web/style/components.ts` (list rows). |
| 17 | 17 | |
| 18 | 18 | The proofs under a forge on `/forges` (#7959) are `row()` `children`, one per public address; the row's trail badge says "reachable" or "not reachable", so the state is a word and not only a colour. No new class. |
| 19 | ||
| 20 | `/pipeline` (#7703) is a `.list` of `row()`s, one per step, each folding (`fold`) to the parts inside it; no new class. The pull page's path line marks a finished path with a `badge("done")`, so the state is a word. |
docs/design/audit.md +1
| @@ -60,6 +60,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 60 | 60 | - `/asked` — `studio/asked_page.ts` · what I answered, as a record · `/asks` · none seen. |
| 61 | 61 | - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen. |
| 62 | 62 | - `/flow` — `pages/flow.ts` · the pipeline from ready issue to live deploy, and the bottleneck · `/system`, `/board`, `/` · none seen. A topbar button points here. |
| 63 | - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen. | |
| 63 | 64 | - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary. |
| 64 | 65 | - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document. |
| 65 | 66 | - `/releases` — `pages/releases.ts` · the forge's kept releases and rollback · `/settings/backups` (both are "go back") · none seen. |
docs/features/pipeline.md added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | # One path: check, review, land, live (#7703) | |
| 2 | ||
| 3 | Every change follows four steps, and a person only needs those four words: | |
| 4 | ||
| 5 | 1. **Check**: types, lint and the tests the change reaches run on the pull's own code. | |
| 6 | 2. **Review**: a reviewer approves the change or asks for changes. | |
| 7 | 3. **Land**: an approved, green pull merges into the default branch. | |
| 8 | 4. **Live**: a release builds the default branch and production serves it. | |
| 9 | ||
| 10 | `/pipeline` says this to anyone. Each step's finer parts (the check queue, the | |
| 11 | proof, the merge queue's gate, the release build and boot check) sit one | |
| 12 | click down in a `<details>`. The live counts per stage stay on `/boards`, | |
| 13 | whose Pipeline panel links back to `/pipeline`. | |
| 14 | ||
| 15 | ## Where a pull is | |
| 16 | ||
| 17 | `src/flow/path.ts` decides it: `pathStep` is plain data in, plain data out, | |
| 18 | and `stepOfPull` reads one pull's facts. The rule is "the earliest step not | |
| 19 | yet passed": | |
| 20 | ||
| 21 | - **check** while a check on the head is queued, running, red or cancelled | |
| 22 | (if no check is recorded, there is nothing to wait for); | |
| 23 | - **review** while it is green (or has no check) and nobody has approved this head; | |
| 24 | - **land** once it is approved and green; | |
| 25 | - **live** once merged into an app's default branch, `done` when a production | |
| 26 | deploy has served since the merge. A merge where nothing deploys ends at | |
| 27 | **land**, `done`. | |
| 28 | ||
| 29 | For an open pull the sentence comes from `nextActor` (`src/flow/next_actor.ts`), | |
| 30 | so the step line and the "Next:" line never disagree. On the check step the | |
| 31 | check is the reason, even before anyone has reviewed. | |
| 32 | ||
| 33 | Under every pull's title (both tabs) the line reads, for example, | |
| 34 | `Check · **Review** · Land · Live — No review yet.`, linked to `/pipeline`. | |
| 35 | A finished path (live, or landed where nothing deploys) carries a "done" badge. | |
| 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. | |
| 38 | `changeHead` computes "who acts next" once and hands it to `stepOfPull`. | |
| 39 | ||
| 40 | ## Not here yet | |
| 41 | ||
| 42 | The rest of #7703 is filed as its own issues: remove the proof (#7779), run | |
| 43 | 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). |
forge.json +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | "api.anthropic.com" |
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"], | |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/pipeline", "/resilience", "/settings"], | |
| 7 | 7 | "run": "bun run src/main.ts", |
| 8 | 8 | "install": "", |
| 9 | 9 | "health": "/healthz", |
src/core/reserved.ts +1
| @@ -65,6 +65,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 65 | 65 | "board", |
| 66 | 66 | "boards", |
| 67 | 67 | "contribute", |
| 68 | "pipeline", | |
| 68 | 69 | "events", |
| 69 | 70 | "explore", |
| 70 | 71 | "family", |
src/flow/path.ts added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | /** | |
| 2 | * The one path a change follows, in four words a person can hold (#7703): | |
| 3 | * check, review, land, live. Every finer step (the check queue, the proof, | |
| 4 | * the merge queue, the release build, the boot check) sits one click down on | |
| 5 | * /boards; this module only says which of the four a pull is at and, in one | |
| 6 | * plain sentence, why it waits there. | |
| 7 | * | |
| 8 | * `pathStep` is plain data in, plain data out; `stepOfPull` reads the facts | |
| 9 | * for one pull, and the pull page, the change view and the MCP `pull` all | |
| 10 | * answer from it. The sentence for an open pull is `nextActor`'s, so the | |
| 11 | * step and "who acts next" never disagree. | |
| 12 | */ | |
| 13 | ||
| 14 | import type { Database } from "bun:sqlite"; | |
| 15 | import { getAppByRepo } from "../core/apps.ts"; | |
| 16 | import type { Pull } from "../core/pulls.ts"; | |
| 17 | import type { Repo } from "../core/repos.ts"; | |
| 18 | import { reviewSummary } from "../core/reviews.ts"; | |
| 19 | import { checkSummary, type RunState } from "../core/runs.ts"; | |
| 20 | import { nextOfPull, type Next } from "./next_actor.ts"; | |
| 21 | ||
| 22 | export const STEPS = ["check", "review", "land", "live"] as const; | |
| 23 | export type Step = (typeof STEPS)[number]; | |
| 24 | ||
| 25 | /** What each step is for, in one line: the /pipeline page and the strip's titles read this. */ | |
| 26 | export const STEP_MEANS: Record<Step, string> = { | |
| 27 | check: "Types, lint and the tests the change reaches run on the pull's own code.", | |
| 28 | review: "A reviewer reads the change and approves it or asks for changes.", | |
| 29 | land: "An approved, green pull merges into the default branch.", | |
| 30 | live: "A release builds the default branch and production starts serving it.", | |
| 31 | }; | |
| 32 | ||
| 33 | export interface PathStep { | |
| 34 | readonly step: Step; | |
| 35 | /** One plain sentence: why it waits here, or that it is done. */ | |
| 36 | readonly why: string; | |
| 37 | /** True once the last step is reached: live in production, or landed where nothing deploys. */ | |
| 38 | readonly done: boolean; | |
| 39 | } | |
| 40 | ||
| 41 | export interface PathInput { | |
| 42 | readonly state: "open" | "merged" | "closed"; | |
| 43 | /** The open pull's check on its head; ignored once merged. */ | |
| 44 | readonly check: RunState | "none"; | |
| 45 | readonly approvals: number; | |
| 46 | /** Who acts next on an open pull (`nextActor`); its reason is the sentence. */ | |
| 47 | readonly next: Next | null; | |
| 48 | /** Merged pulls only: whether the base is an app's default branch, and whether production deployed since. */ | |
| 49 | readonly deploys?: boolean; | |
| 50 | readonly liveSince?: boolean; | |
| 51 | } | |
| 52 | ||
| 53 | const sentence = (s: string) => `${s.charAt(0).toUpperCase()}${s.slice(1)}.`; | |
| 54 | ||
| 55 | /** Which step the pull is at; null for a pull closed without landing. */ | |
| 56 | export function pathStep(i: PathInput): PathStep | null { | |
| 57 | if (i.state === "closed") return null; | |
| 58 | if (i.state === "merged") { | |
| 59 | if (i.deploys !== true) | |
| 60 | return { step: "land", why: "Landed; nothing deploys from this branch, so this is the end of its path.", done: true }; | |
| 61 | return i.liveSince === true | |
| 62 | ? { step: "live", why: "Landed and live in production.", done: true } | |
| 63 | : { step: "live", why: "Landed; it goes live with the next production release.", done: false }; | |
| 64 | } | |
| 65 | // The earliest of the four not yet passed: a check on this head that is not | |
| 66 | // green, then an approval. No check recorded is no check to wait for. | |
| 67 | const checking = i.check !== "ok" && i.check !== "none"; | |
| 68 | const step: Step = checking ? "check" : i.approvals > 0 ? "land" : "review"; | |
| 69 | // nextActor names review before a running check; on the check step the check is the reason. | |
| 70 | const why = | |
| 71 | step === "check" && (i.next === null || i.next.who === "reviewer") | |
| 72 | ? CHECK_WORDS[i.check as Exclude<RunState, "ok">] | |
| 73 | : (i.next?.why ?? "waiting"); | |
| 74 | return { step, why: sentence(why), done: false }; | |
| 75 | } | |
| 76 | ||
| 77 | const CHECK_WORDS: Record<Exclude<RunState, "ok">, string> = { | |
| 78 | queued: "its check is queued", | |
| 79 | running: "its check is running", | |
| 80 | failed: "its check is red", | |
| 81 | cancelled: "its check was cancelled", | |
| 82 | }; | |
| 83 | ||
| 84 | /** Whether production for `repo` has deployed since `at`. */ | |
| 85 | function deployedSince(db: Database, repoId: string, at: string): boolean { | |
| 86 | return ( | |
| 87 | db | |
| 88 | .query<{ n: number }, [string, string]>( | |
| 89 | `SELECT 1 AS n FROM run | |
| 90 | WHERE repo_id = ?1 AND kind = 'deploy' AND env = 'production' | |
| 91 | AND created_at >= ?2 AND serving_at IS NOT NULL LIMIT 1`, | |
| 92 | ) | |
| 93 | .get(repoId, at) !== null | |
| 94 | ); | |
| 95 | } | |
| 96 | ||
| 97 | /** | |
| 98 | * The step of `pull` in `repo`. `live` is the open pull's head and conflicts | |
| 99 | * as the caller already read them, and `next` is the `nextOfPull` it already | |
| 100 | * computed for the same head, so one render reads the facts once. | |
| 101 | */ | |
| 102 | export function stepOfPull( | |
| 103 | db: Database, | |
| 104 | repo: Repo, | |
| 105 | pull: Pull, | |
| 106 | live: { readonly headOid: string; readonly conflicts: number }, | |
| 107 | next: Next | null = pull.state === "open" && pull.mergedAt === null ? nextOfPull(db, pull, live) : null, | |
| 108 | ): PathStep | null { | |
| 109 | if (pull.mergedAt !== null) { | |
| 110 | const deploys = pull.baseBranch === repo.defaultBranch && getAppByRepo(db, repo.id) !== undefined; | |
| 111 | return pathStep({ | |
| 112 | state: "merged", | |
| 113 | check: "ok", | |
| 114 | approvals: 0, | |
| 115 | next: null, | |
| 116 | deploys, | |
| 117 | liveSince: deploys && deployedSince(db, repo.id, pull.mergedAt), | |
| 118 | }); | |
| 119 | } | |
| 120 | if (pull.state !== "open") return null; | |
| 121 | return pathStep({ | |
| 122 | state: "open", | |
| 123 | check: checkSummary(db, pull.id, live.headOid).state, | |
| 124 | approvals: reviewSummary(db, pull.id, live.headOid).approvals, | |
| 125 | next, | |
| 126 | }); | |
| 127 | } |
src/http/mcp/tools/pull.ts +6 −1
| @@ -6,6 +6,7 @@ import { previewEnv } from "../../../apps/data.ts"; | ||
| 6 | 6 | import { checkStale } from "../../../checks/stale.ts"; |
| 7 | 7 | import { checkSummary } from "../../../core/runs.ts"; |
| 8 | 8 | import { nextOfPull } from "../../../flow/next_actor.ts"; |
| 9 | import { stepOfPull } from "../../../flow/path.ts"; | |
| 9 | 10 | import { appView } from "../../../actions/apps.ts"; |
| 10 | 11 | import { countThread, EMBEDDED_COMMENTS, listComments } from "../../../core/issues.ts"; |
| 11 | 12 | import { isCrew } from "../../../core/agents.ts"; |
| @@ -14,7 +15,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 14 | 15 | |
| 15 | 16 | export const tool: Tool = { |
| 16 | 17 | name: "pull", |
| 17 | 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 `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), 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", | |
| 18 | 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), `step: { step, why, done }` (check, review, land or live; null once closed unlanded), and `comments` (the newest 50: author, body, at, `untrusted` on an outsider's), `commentsTotal` and `commentsNext` (older: `GET …/issues/:number/comments?cursor=`), 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", | |
| 18 | 19 | description: |
| 19 | 20 | "A pull: title, state, head, base; open: `mergeable`, `conflicts`, `checkStale`, `check`.", |
| 20 | 21 | inputSchema: { |
| @@ -70,6 +71,10 @@ export const tool: Tool = { | ||
| 70 | 71 | headOid: pull.headOid, |
| 71 | 72 | conflicts: state?.conflicts?.length ?? 0, |
| 72 | 73 | }), |
| 74 | step: stepOfPull(ctx.app.db, repo, pull, { | |
| 75 | headOid: pull.headOid, | |
| 76 | conflicts: state?.conflicts?.length ?? 0, | |
| 77 | }), | |
| 73 | 78 | ...(state ?? {}), |
| 74 | 79 | }); |
| 75 | 80 | // What the card needs and the text does not say: where the pull lives |
src/http/server.ts +3
| @@ -21,6 +21,7 @@ import { systemPage, systemStream } from "../web/pages/system.ts"; | ||
| 21 | 21 | import { teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts"; |
| 22 | 22 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 23 | 23 | import { contributePage } from "../web/pages/contribute.ts"; |
| 24 | import { pipelinePage } from "../web/pages/pipeline.ts"; | |
| 24 | 25 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 25 | 26 | import { usagePage } from "../web/pages/usage.ts"; |
| 26 | 27 | import { spendPage } from "../web/pages/spend-tab.ts"; |
| @@ -857,6 +858,8 @@ export function buildRouter(): Router<Handler> { | ||
| 857 | 858 | router.add("GET", "/system", systemPage); |
| 858 | 859 | router.add("GET", "/system/stream", systemStream); |
| 859 | 860 | router.add("GET", "/contribute", contributePage); |
| 861 | // The one path a change follows, in four words (#7703); literal, above /:owner. | |
| 862 | router.add("GET", "/pipeline", pipelinePage); | |
| 860 | 863 | router.add("GET", "/settings/telemetry", telemetryPage); |
| 861 | 864 | router.add("POST", "/settings/telemetry", telemetrySet); |
| 862 | 865 | // Each THREATS.md protection against the last gate; literal, above /:owner. |
src/tui/screens/pull.ts +17 −1
| @@ -63,6 +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 | 67 | import { getPull, pullDir, variantSiblings, type Pull } from "../../core/pulls.ts"; |
| 67 | 68 | import { voteVariant } from "../../actions/variants.ts"; |
| 68 | 69 | import { getRepoById, type Repo } from "../../core/repos.ts"; |
| @@ -225,6 +226,8 @@ export function pullScreen( | ||
| 225 | 226 | let threads: Thread[] = []; |
| 226 | 227 | /** Who acts next, for a local open pull; the page says the same line. */ |
| 227 | 228 | let next: Next | null = null; |
| 229 | /** Which of check, review, land, live the pull is at; the page's step line. */ | |
| 230 | let step: PathStep | null = null; | |
| 228 | 231 | /** Where each team the issue names stands; empty when it names none. */ |
| 229 | 232 | let teams: TeamVerdict[] = []; |
| 230 | 233 | /** Settled threads the reader has opened back up, for this visit only. */ |
| @@ -324,6 +327,19 @@ export function pullScreen( | ||
| 324 | 327 | headOid: row.headOid || (facts?.headOid ?? ""), |
| 325 | 328 | conflicts: facts?.conflicts.length ?? 0, |
| 326 | 329 | }); |
| 330 | step = | |
| 331 | row === undefined || settings === undefined | |
| 332 | ? null | |
| 333 | : stepOfPull( | |
| 334 | f.db, | |
| 335 | settings, | |
| 336 | row, | |
| 337 | { | |
| 338 | headOid: row.headOid || (facts?.headOid ?? ""), | |
| 339 | conflicts: facts?.conflicts.length ?? 0, | |
| 340 | }, | |
| 341 | next, | |
| 342 | ); | |
| 327 | 343 | checkProgress = runningProgress(f.app.config.dataDir, check); |
| 328 | 344 | const spec = |
| 329 | 345 | repo === undefined || !isApp |
| @@ -1002,7 +1018,7 @@ export function pullScreen( | ||
| 1002 | 1018 | x1, |
| 1003 | 1019 | head!.y + 1, |
| 1004 | 1020 | truncate( |
| 1005 | ` ${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}`}${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: ${step.step}${step.done ? " (done)" : ""}`}${next === null ? "" : ` · next: ${next.who}, ${next.why}`}`, | |
| 1006 | 1022 | Math.max(0, edge - x1), |
| 1007 | 1023 | ), |
| 1008 | 1024 | style(c.mutedFg, c.bg), |
src/web/pages/flow-panel.ts +1 −1
| @@ -211,7 +211,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 211 | 211 | </tbody> |
| 212 | 212 | </table> |
| 213 | 213 | </div>`, |
| 214 | { head: html`<h2>Pipeline</h2><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false }, | |
| 214 | { head: html`<h2>Pipeline</h2><a class="muted small" href="/pipeline">The four steps</a><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false }, | |
| 215 | 215 | )} |
| 216 | 216 | ${verifiedCard(readVerified(app, reader.kind === "owner"), now)} |
| 217 | 217 | ${hotFilesCard(app, reader)} |
src/web/pages/pipeline.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * /pipeline, open to anyone: the one path a change follows, in four words | |
| 3 | * (#7703). A newcomer reads it top to bottom and can say how a change gets | |
| 4 | * from a pull to production; each step's finer machinery is one click down | |
| 5 | * (the row folds), and the live counts per stage are on /boards. Every pull | |
| 6 | * page links here from its path line (`changeHead`), and the MCP `pull` | |
| 7 | * answers the same four words as `step`. | |
| 8 | */ | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { STEP_MEANS, STEPS, type Step } from "../../flow/path.ts"; | |
| 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 12 | import { html } from "../html.ts"; | |
| 13 | import { page } from "../layout.ts"; | |
| 14 | import { card, row } from "../ui.ts"; | |
| 15 | ||
| 16 | /** What a pull waits on at each step, what only that step catches, and the parts inside it. */ | |
| 17 | export const STEP_DETAIL: Record<Step, { waits: string; catches: string; inside: readonly string[] }> = { | |
| 18 | check: { | |
| 19 | waits: "a free check slot, then the check itself (minutes).", | |
| 20 | catches: "a change that does not type-check, breaks the lint, or fails a test its own files reach.", | |
| 21 | inside: [ | |
| 22 | "The gate script and the type check, on the pull's head.", | |
| 23 | "The tests the change reaches (the fast tier), sharded across the check's machine.", | |
| 24 | "A red main pauses pulls' checks for a while, so a pull is not blamed for main's red.", | |
| 25 | ], | |
| 26 | }, | |
| 27 | review: { | |
| 28 | waits: "a reviewer: an agent for agent pulls, or a person who can write the repository.", | |
| 29 | catches: "what no test asks: the wrong change, an unsafe one, or one nobody asked for.", | |
| 30 | inside: [ | |
| 31 | "A verdict counts for the head it was given on; a new push asks for a fresh look.", | |
| 32 | "Changes asked send the pull back to its builder, who pushes and is checked again.", | |
| 33 | ], | |
| 34 | }, | |
| 35 | land: { | |
| 36 | waits: "the merge queue, which merges approved, green pulls into the default branch in order.", | |
| 37 | catches: "a pull that no longer merges onto the base as it is now.", | |
| 38 | inside: [ | |
| 39 | "Before it joins the queue, a pull is proved once on the base's tip; that proof is being removed (#7779).", | |
| 40 | "The merge queue's gate: lint and types on the pull merged with the base's tip.", | |
| 41 | "A conflicting pull goes back to its builder to rebase before it can land.", | |
| 42 | ], | |
| 43 | }, | |
| 44 | live: { | |
| 45 | waits: "the next release of the default branch.", | |
| 46 | catches: "a tree that builds and checks but does not boot or answer as a website.", | |
| 47 | inside: [ | |
| 48 | "The release build, and a boot of the built image on an empty data directory.", | |
| 49 | "The new version takes over the port only once it answers healthy.", | |
| 50 | "After the swap the forge loads its own public pages; a failure is paged.", | |
| 51 | ], | |
| 52 | }, | |
| 53 | }; | |
| 54 | ||
| 55 | const title = (s: Step) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`; | |
| 56 | ||
| 57 | // GET /pipeline | |
| 58 | export function pipelinePage(ctx: Ctx): Response { | |
| 59 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 60 | const rendering = performance.now(); | |
| 61 | const body = html`<div class="page-head"> | |
| 62 | <h1>How a change lands</h1> | |
| 63 | <p class="muted"> | |
| 64 | Every change follows the same four steps: | |
| 65 | <strong>check, review, land, live</strong>. A pull's page says which | |
| 66 | one it is at and, in one sentence, why it waits there. | |
| 67 | </p> | |
| 68 | </div> | |
| 69 | <ol class="list" id="path"> | |
| 70 | ${STEPS.map((s, i) => { | |
| 71 | const d = STEP_DETAIL[s]; | |
| 72 | return row({ | |
| 73 | id: s, | |
| 74 | data: { step: s }, | |
| 75 | title: `${i + 1}. ${title(s)}`, | |
| 76 | meta: html`${STEP_MEANS[s]}`, | |
| 77 | fold: { | |
| 78 | id: `inside-${s}`, | |
| 79 | open: false, | |
| 80 | body: html`<div class="card-body"> | |
| 81 | <p class="small">It waits for ${d.waits} Only this step catches ${d.catches}</p> | |
| 82 | <ul class="small">${d.inside.map((line) => html`<li>${line}</li>`)}</ul> | |
| 83 | </div>`, | |
| 84 | }, | |
| 85 | }); | |
| 86 | })} | |
| 87 | </ol> | |
| 88 | ${card( | |
| 89 | html`<div class="card-body"> | |
| 90 | <p class="small"> | |
| 91 | The whole test suite does not stand in the path: a pull's check runs the tests its change reaches, | |
| 92 | and the rest of the path runs lint and types. An admin sees how many changes sit at each step right now on | |
| 93 | <a href="/boards">Boards</a>. | |
| 94 | </p> | |
| 95 | <p class="muted small">For agents: the MCP tool <span class="mono">pull</span> answers <span class="mono">step: { step, why, done }</span> with these four words.</p> | |
| 96 | </div>`, | |
| 97 | { head: html`<h2>Where to look next</h2>` }, | |
| 98 | )}`; | |
| 99 | const response = page({ ctx, title: "How a change lands", body }); | |
| 100 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 101 | return response; | |
| 102 | } |
src/web/pages/pull.ts +1 −2
| @@ -107,7 +107,6 @@ import { previewEnv } from "../../apps/data.ts"; | ||
| 107 | 107 | import { specFindings } from "./pull-spec.ts"; |
| 108 | 108 | import { pullPreviewFindings } from "../../core/preview_findings.ts"; |
| 109 | 109 | import { crewActivityCard } from "./runs.ts"; |
| 110 | import { nextOfPull } from "../../flow/next_actor.ts"; | |
| 111 | 110 | import { holdCard } from "../studio/hold.ts"; |
| 112 | 111 | import { |
| 113 | 112 | changeHead, |
| @@ -978,7 +977,7 @@ export async function pullFilesPage(ctx: Ctx): Promise<Response> { | ||
| 978 | 977 | compact: true, |
| 979 | 978 | ownTitle: true, |
| 980 | 979 | })} |
| 981 | ${changeHead(ctx, repo, pull, nextOfPull(ctx.app.db, pull, { headOid: status.headOid, conflicts: status.conflicts.length }))} | |
| 980 | ${changeHead(ctx, repo, pull, { headOid: status.headOid, conflicts: status.conflicts.length })} | |
| 982 | 981 | ${changeTabs(repo, pull, "files")} |
| 983 | 982 | <p class="files-where muted small" id="files"> |
| 984 | 983 | <span class="mono">${headLabel} → ${pull.baseBranch}</span> |
src/web/studio/change.ts +35 −3
| @@ -52,6 +52,7 @@ import { | ||
| 52 | 52 | btn, |
| 53 | 53 | card, |
| 54 | 54 | confirmAction, |
| 55 | badge, | |
| 55 | 56 | icon, |
| 56 | 57 | originTag, |
| 57 | 58 | seg, |
| @@ -83,7 +84,8 @@ import { previewVision } from "../../crew/vision.ts"; | ||
| 83 | 84 | import { visionPreviewCard } from "./direction.ts"; |
| 84 | 85 | import { holdCard } from "./hold.ts"; |
| 85 | 86 | import { pullMayRun } from "../../core/pull_trust.ts"; |
| 86 | import { nextOfPull, type Next } from "../../flow/next_actor.ts"; | |
| 87 | import { nextOfPull } from "../../flow/next_actor.ts"; | |
| 88 | import { STEP_MEANS, STEPS, stepOfPull, type PathStep } from "../../flow/path.ts"; | |
| 87 | 89 | import { changeHref } from "./nouns.ts"; |
| 88 | 90 | import { pullWord, servingHistory, wordBadge, type WordCtx } from "./words.ts"; |
| 89 | 91 | |
| @@ -187,7 +189,17 @@ function reviewEntry( | ||
| 187 | 189 | /** A change's head, the same on both of its tabs: the title, its state, |
| 188 | 190 | * who opened it and in answer to whom, where it came from, and what the |
| 189 | 191 | * crew spent on it. */ |
| 190 | export function changeHead(ctx: Ctx, repo: Repo, pull: Pull, next: Next | null = null): Html { | |
| 192 | export function changeHead( | |
| 193 | ctx: Ctx, | |
| 194 | repo: Repo, | |
| 195 | pull: Pull, | |
| 196 | live: { readonly headOid: string; readonly conflicts: number } | null = null, | |
| 197 | ): Html { | |
| 198 | const next = live === null ? null : nextOfPull(ctx.app.db, pull, live); | |
| 199 | const step = | |
| 200 | live === null && pull.mergedAt === null | |
| 201 | ? null | |
| 202 | : stepOfPull(ctx.app.db, repo, pull, live ?? { headOid: pull.headOid, conflicts: 0 }, next); | |
| 191 | 203 | const who = toldBy(ctx.app.db, pull); |
| 192 | 204 | const marks = marksByName(ctx.app.db, [pull.authorName, who ?? ""]); |
| 193 | 205 | return html`<div class="page-head"> |
| @@ -225,6 +237,7 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull, next: Next | null = | ||
| 225 | 237 | >`, |
| 226 | 238 | )} |
| 227 | 239 | </div> |
| 240 | ${when(step, pathLine)} | |
| 228 | 241 | ${when( |
| 229 | 242 | next, |
| 230 | 243 | (n) => html`<p class="next-actor muted small">Next: ${n.who}, ${n.why}.</p>`, |
| @@ -232,6 +245,25 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull, next: Next | null = | ||
| 232 | 245 | </div>`; |
| 233 | 246 | } |
| 234 | 247 | |
| 248 | /** | |
| 249 | * Where the pull is on the one path (#7703): the four words, the current one | |
| 250 | * marked, and why it waits there. The words link to /pipeline, which says | |
| 251 | * what each one means and holds the finer steps. | |
| 252 | */ | |
| 253 | export function pathLine(p: PathStep): Html { | |
| 254 | const at = STEPS.indexOf(p.step); | |
| 255 | const words = STEPS.map((s, i) => { | |
| 256 | const label = `${s.charAt(0).toUpperCase()}${s.slice(1)}`; | |
| 257 | if (i === at) | |
| 258 | return html`<strong aria-current="step" title="${STEP_MEANS[s]}">${label}</strong>${p.done ? html` ${badge("done", { tone: "success" })}` : null}`; | |
| 259 | return html`<span title="${STEP_MEANS[s]}">${label}</span>`; | |
| 260 | }); | |
| 261 | return html`<p class="muted small" data-path="${p.step}"> | |
| 262 | <a href="/pipeline">${words.map((w, i) => html`${i > 0 ? " · " : ""}${w}`)}</a> | |
| 263 | — ${p.why} | |
| 264 | </p>`; | |
| 265 | } | |
| 266 | ||
| 235 | 267 | /** The change's two steps: the change itself, and its Files one tab |
| 236 | 268 | * deeper — never a mode, the same address family either way. */ |
| 237 | 269 | export function changeTabs( |
| @@ -597,7 +629,7 @@ async function changeConversation( | ||
| 597 | 629 | compact: true, |
| 598 | 630 | ownTitle: true, |
| 599 | 631 | })} |
| 600 | ${changeHead(ctx, repo, pull, status === undefined ? null : nextOfPull(db, pull, { headOid, conflicts: status.conflicts.length }))} | |
| 632 | ${changeHead(ctx, repo, pull, status === undefined ? null : { headOid, conflicts: status.conflicts.length })} | |
| 601 | 633 | ${changeTabs(repo, pull, "change")} |
| 602 | 634 | ${when(siblings.length > 0, () => |
| 603 | 635 | html`<p class="muted small variant-link"> |
test/pipeline-path.test.ts added +208
| @@ -0,0 +1,208 @@ | ||
| 1 | /** The one path (#7703): four words, each pull at one of them with a sentence, /pipeline explaining them, and the MCP answering the same. */ | |
| 2 | import { afterAll, beforeAll, describe, 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 { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 13 | import { getPull } from "../src/core/pulls.ts"; | |
| 14 | import { getRepoById } from "../src/core/repos.ts"; | |
| 15 | import { pathStep, stepOfPull, STEPS } from "../src/flow/path.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | describe("pathStep", () => { | |
| 22 | const reviewer = { who: "reviewer", why: "no review yet" } as const; | |
| 23 | test("the four words, in order", () => { | |
| 24 | expect([...STEPS]).toEqual(["check", "review", "land", "live"]); | |
| 25 | }); | |
| 26 | test("a running check is the check step, and says so even though nobody has reviewed", () => { | |
| 27 | expect(pathStep({ state: "open", check: "running", approvals: 0, next: reviewer })).toEqual({ | |
| 28 | step: "check", | |
| 29 | why: "Its check is running.", | |
| 30 | done: false, | |
| 31 | }); | |
| 32 | }); | |
| 33 | test("a red check keeps the builder's reason", () => { | |
| 34 | const s = pathStep({ state: "open", check: "failed", approvals: 0, next: { who: "builder", why: "its check is red" } }); | |
| 35 | expect(s?.step).toBe("check"); | |
| 36 | expect(s?.why).toBe("Its check is red."); | |
| 37 | }); | |
| 38 | test("green and unreviewed is review; no check recorded is not a check to wait for", () => { | |
| 39 | expect(pathStep({ state: "open", check: "ok", approvals: 0, next: reviewer })?.step).toBe("review"); | |
| 40 | expect(pathStep({ state: "open", check: "none", approvals: 0, next: reviewer })).toEqual({ | |
| 41 | step: "review", | |
| 42 | why: "No review yet.", | |
| 43 | done: false, | |
| 44 | }); | |
| 45 | }); | |
| 46 | test("approved and green is land, with the queue's reason", () => { | |
| 47 | const s = pathStep({ state: "open", check: "ok", approvals: 1, next: { who: "queue", why: "approved and green" } }); | |
| 48 | expect(s).toEqual({ step: "land", why: "Approved and green.", done: false }); | |
| 49 | }); | |
| 50 | test("merged: live once production deployed, waiting for a release before, done where nothing deploys", () => { | |
| 51 | const merged = { state: "merged", check: "ok", approvals: 0, next: null } as const; | |
| 52 | expect(pathStep({ ...merged, deploys: true, liveSince: true })).toEqual({ step: "live", why: "Landed and live in production.", done: true }); | |
| 53 | expect(pathStep({ ...merged, deploys: true, liveSince: false })?.done).toBe(false); | |
| 54 | expect(pathStep({ ...merged, deploys: false })).toMatchObject({ step: "land", done: true }); | |
| 55 | }); | |
| 56 | test("a pull closed without landing has no step", () => { | |
| 57 | expect(pathStep({ state: "closed", check: "ok", approvals: 0, next: null })).toBeNull(); | |
| 58 | }); | |
| 59 | }); | |
| 60 | ||
| 61 | let dataDir: string; | |
| 62 | let app: App; | |
| 63 | let servers: Servers; | |
| 64 | let alice: User; | |
| 65 | let token = ""; | |
| 66 | const numbers: Record<string, number> = {}; | |
| 67 | const cells = process.env["FORGE_CELLS"]; | |
| 68 | ||
| 69 | const read = async (path: string): Promise<string> => | |
| 70 | (await fetch(`${servers.url}${path}`, { | |
| 71 | headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` }, | |
| 72 | })).text(); | |
| 73 | ||
| 74 | beforeAll(async () => { | |
| 75 | process.env["FORGE_CELLS"] = "off"; | |
| 76 | dataDir = await mkdtemp(join(tmpdir(), "forge-path-")); | |
| 77 | app = createApp({ dataDir, port: 0 }); | |
| 78 | servers = await serve(app); | |
| 79 | const made = await createUser(app.db, { | |
| 80 | origin: "seed", | |
| 81 | trusted: true, | |
| 82 | username: "alice", | |
| 83 | email: "[email protected]", | |
| 84 | password: "a-good-passphrase", | |
| 85 | adminIfFirst: true, | |
| 86 | }); | |
| 87 | if (!made.ok) throw new Error(String(made.error)); | |
| 88 | alice = made.value; | |
| 89 | token = createToken(app.db, alice.id, "rw", "rw").token; | |
| 90 | for (const name of ["open", "merged"]) { | |
| 91 | const repo = await createRepo(app, alice, { name, visibility: "public" }); | |
| 92 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 93 | for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) { | |
| 94 | const done = await commitToBranch(app, alice, repo.value, { | |
| 95 | ...(branch === undefined ? {} : { branch }), | |
| 96 | message: "change", | |
| 97 | files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }], | |
| 98 | }); | |
| 99 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 100 | } | |
| 101 | const pull = await openPull(app, alice, repo.value, { title: name, body: "", baseBranch: "main", headBranch: "topic" }); | |
| 102 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); | |
| 103 | numbers[name] = pull.value.number; | |
| 104 | if (name === "merged") { | |
| 105 | const done = await mergePull(app, alice, repo.value, pull.value, "merge"); | |
| 106 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 107 | } | |
| 108 | } | |
| 109 | }); | |
| 110 | ||
| 111 | afterAll(async () => { | |
| 112 | await servers.stop(); | |
| 113 | app.close(); | |
| 114 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 115 | else process.env["FORGE_CELLS"] = cells; | |
| 116 | await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); | |
| 117 | }); | |
| 118 | ||
| 119 | test("/pipeline names the four steps in order, each with its finer parts one click down", async () => { | |
| 120 | const res = await fetch(`${servers.url}/pipeline`); | |
| 121 | expect(res.status).toBe(200); | |
| 122 | const body = await res.text(); | |
| 123 | const at = STEPS.map((s) => body.indexOf(`data-step="${s}"`)); | |
| 124 | expect(at.every((i) => i > 0)).toBe(true); | |
| 125 | expect([...at].sort((a, b) => a - b)).toEqual(at); | |
| 126 | expect(body.match(/<details id="inside-/g)?.length).toBe(4); | |
| 127 | expect(body).toContain('href="/boards"'); | |
| 128 | }); | |
| 129 | ||
| 130 | test("an open pull says it is at review and why, on both tabs, linked to /pipeline", async () => { | |
| 131 | for (const tab of ["", "/files"]) { | |
| 132 | const body = await read(`/alice/open/pulls/${numbers["open"]}${tab}`); | |
| 133 | expect(body).toContain('data-path="review"'); | |
| 134 | expect(body).toMatch(/<strong aria-current="step"[^>]*>Review<\/strong>/); | |
| 135 | expect(body).toContain("— No review yet."); | |
| 136 | expect(body).toContain('<a href="/pipeline">'); | |
| 137 | } | |
| 138 | }); | |
| 139 | ||
| 140 | test("a merged pull in a repository with no app says its path ends at land", async () => { | |
| 141 | const body = await read(`/alice/merged/pulls/${numbers["merged"]}`); | |
| 142 | expect(body).toContain('data-path="land"'); | |
| 143 | expect(body).toContain("nothing deploys from this branch"); | |
| 144 | }); | |
| 145 | ||
| 146 | test("the MCP pull answers the same step", async () => { | |
| 147 | const res = await fetch(`${servers.url}/mcp`, { | |
| 148 | method: "POST", | |
| 149 | headers: { | |
| 150 | "content-type": "application/json", | |
| 151 | accept: "application/json, text/event-stream", | |
| 152 | authorization: `Bearer ${token}`, | |
| 153 | }, | |
| 154 | body: JSON.stringify({ | |
| 155 | jsonrpc: "2.0", | |
| 156 | id: 1, | |
| 157 | method: "tools/call", | |
| 158 | params: { name: "pull", arguments: { repo: "alice/open", number: numbers["open"] } }, | |
| 159 | }), | |
| 160 | }); | |
| 161 | const out: any = await res.json(); | |
| 162 | expect(out.result.isError).not.toBe(true); | |
| 163 | const pull = JSON.parse(out.result.content[0].text) as { step: { step: string; why: string; done: boolean } }; | |
| 164 | expect(pull.step).toEqual({ step: "review", why: "No review yet.", done: false }); | |
| 165 | }); | |
| 166 | ||
| 167 | test("a merged pull into an app is landed until production has served a deploy since the merge, then live", async () => { | |
| 168 | const made = await createRepo(app, alice, { name: "shipped", visibility: "public" }); | |
| 169 | if (!made.ok) throw new Error(String(made.error)); | |
| 170 | for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) { | |
| 171 | const done = await commitToBranch(app, alice, made.value, { | |
| 172 | ...(branch === undefined ? {} : { branch }), | |
| 173 | message: "change", | |
| 174 | files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }], | |
| 175 | }); | |
| 176 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 177 | } | |
| 178 | const opened = await openPull(app, alice, made.value, { title: "ship", body: "", baseBranch: "main", headBranch: "topic" }); | |
| 179 | if (!opened.ok) throw new Error(JSON.stringify(opened.error)); | |
| 180 | const merged = await mergePull(app, alice, made.value, opened.value, "merge"); | |
| 181 | if (!merged.ok) throw new Error(JSON.stringify(merged.error)); | |
| 182 | const record = createAppRecord(app.db, { repoId: made.value.id, slug: "shipped", access: "public" }); | |
| 183 | if (!record.ok) throw new Error(record.error); | |
| 184 | ||
| 185 | const pull = getPull(app.db, made.value.id, opened.value.number)!; | |
| 186 | const repo = getRepoById(app.db, made.value.id)!; | |
| 187 | const live = { headOid: pull.headOid, conflicts: 0 }; | |
| 188 | const deploy = (id: string, at: string, serving: string | null, env = "production") => | |
| 189 | app.db | |
| 190 | .query("INSERT INTO run (id, repo_id, kind, env, state, created_at, serving_at) VALUES (?, ?, 'deploy', ?, 'ok', ?, ?)") | |
| 191 | .run(id, made.value.id, env, at, serving); | |
| 192 | ||
| 193 | // No deploy yet: landed, waiting for a release. | |
| 194 | expect(stepOfPull(app.db, repo, pull, live)).toEqual({ | |
| 195 | step: "live", | |
| 196 | why: "Landed; it goes live with the next production release.", | |
| 197 | done: false, | |
| 198 | }); | |
| 199 | // A production deploy that predates the merge, one still not serving, and a preview do not count. | |
| 200 | deploy("d-before", "2000-01-01T00:00:00.000Z", "2000-01-01T00:01:00.000Z"); | |
| 201 | deploy("d-booting", "2999-01-01T00:00:00.000Z", null); | |
| 202 | deploy("d-preview", "2999-01-01T00:00:00.000Z", "2999-01-01T00:01:00.000Z", "pull-1"); | |
| 203 | expect(stepOfPull(app.db, repo, pull, live)?.done).toBe(false); | |
| 204 | // One serving after the merge makes it live. | |
| 205 | deploy("d-after", "2999-01-01T00:00:00.000Z", "2999-01-01T00:01:00.000Z"); | |
| 206 | expect(stepOfPull(app.db, repo, pull, live)).toEqual({ step: "live", why: "Landed and live in production.", done: true }); | |
| 207 | expect(await read(`/alice/shipped/pulls/${opened.value.number}`)).toContain('data-path="live"'); | |
| 208 | }); |
test/pull-next-tui.test.ts +2
| @@ -70,6 +70,8 @@ test("the terminal's pull screen says who acts next, as the page does", async () | ||
| 70 | 70 | await settleApp(tui); |
| 71 | 71 | await Bun.sleep(20); |
| 72 | 72 | } |
| 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()); | |
| 73 | 75 | } finally { |
| 74 | 76 | tui.stop(); |
| 75 | 77 | } |
new above
offline