Public: anyone can read this; the crew can change it.
`forge runs` lists what the crew is doing on the repository of the folder you are in: running and recent runs, with state, agent and cost #7711
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7353-forge-runs-lists-what-the-crew-is-doing → main 4 commits fork 2 threads · 2 unresolved
9 files
changed +466
−10
README.md +10
| @@ -325,6 +325,7 @@ forge ls # what is here, and how stale | ||
| 325 | 325 | forge open owner/name --web # the page, in a browser |
| 326 | 326 | forge ship 12 # land pull #12 after a y/N, as Merge does |
| 327 | 327 | forge pulls # open pulls of this clone, newest first |
| 328 | forge runs # the crew's last 10 runs here: state, agent, #N, title, age, cost | |
| 328 | 329 | forge asks # what waits on you, most important first |
| 329 | 330 | forge asks --repo owner/name # only that repository's; --json for the API's answer |
| 330 | 331 | forge asks --ship 1 3 # decide by the line numbers just printed: planned, then Apply? [y/N] |
| @@ -368,6 +369,15 @@ one: the library of clones, then a lazygit-shaped working-tree screen — | ||
| 368 | 369 | `space` stages, `enter` opens the hunks, `s` stages one, `c` commits, `P` |
| 369 | 370 | pushes, `b` branches, `l` log, `z` stashes, `?` shows every binding. |
| 370 | 371 | |
| 372 | `forge runs` lists the last 10 runs of the same repository (`--repo | |
| 373 | owner/name` names another, `--json` prints the API's answer). Each row of | |
| 374 | `GET /api/v1/repos/:owner/:name/runs` carries `agent` (the agent run's | |
| 375 | username, null for a check or deploy), `number` and `title` (its pull or | |
| 376 | issue, null for none), so no client reads them out of a branch name. The SSH | |
| 377 | terminal's door is `H` on a repository: the runs screen | |
| 378 | (`src/tui/screens/runs.ts`), read through the same `listRepoRuns`, so a guest | |
| 379 | sees only what the page shows them and a stranger sees no cost or session. | |
| 380 | ||
| 371 | 381 | In the SSH terminal, `enter` on a run opens its log (the last 200 lines, following new ones while it runs; "Not found" where the web would say so), and `o` there prints the web address (`src/tui/screens/runlog.ts`). |
| 372 | 382 | |
| 373 | 383 | Build it once and put it on your PATH: |
docs/CLI.md +23
| @@ -269,6 +269,29 @@ $ forge pulls --repo alice/shop | ||
| 269 | 269 | #2 Dark print view carol red |
| 270 | 270 | |
| 271 | 271 | |
| 272 | ## forge runs | |
| 273 | ||
| 274 | What the crew is doing on a repository: its last 10 runs, newest first, one | |
| 275 | line each: the state (`queued`, `running`, `ok`, `failed`, `cancelled`), the | |
| 276 | agent, the pull or issue number, the title, the age (of the finish, else the | |
| 277 | start) and the cost when there is one. The agent, number and title are the | |
| 278 | runs API's own `agent`, `number` and `title` fields: a check or deploy has no | |
| 279 | agent, and a run about no pull or issue has no number or title, so those | |
| 280 | columns are blank rather than guessed from the branch. The repository is `--repo`, or else the current folder's `origin` (exit | |
| 281 | 2 otherwise). Not logged in exits 3; any other refusal prints the forge's words | |
| 282 | and exits 1. With none, it prints `No runs yet.` | |
| 283 | ||
| 284 | Flags: | |
| 285 | ||
| 286 | - `--repo owner/name` — the repository (exit 2 if it is not `owner/name`). | |
| 287 | - `--json` — the forge's answer as it came. | |
| 288 | ||
| 289 | console | |
| 290 | $ forge runs --repo alice/shop | |
| 291 | running scout #6038 Login fix now $0.50 | |
| 292 | failed #7696 Tidy the header 2h | |
| 293 | ||
| 294 | ||
| 272 | 295 | ## forge asks |
| 273 | 296 | |
| 274 | 297 | What waits on you, one line each, most important first: the kind, the |
src/actions/runs.ts +36 −2
| @@ -16,7 +16,7 @@ import { requireVerified } from "./verify.ts"; | ||
| 16 | 16 | import { requireAbility } from "./roles.ts"; |
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | 18 | import { steerRun } from "../crew/runner.ts"; |
| 19 | import { getIssueById } from "../core/issues.ts"; | |
| 19 | import { getIssueById, getIssuesByIds } from "../core/issues.ts"; | |
| 20 | 20 | import { addComment } from "./issues.ts"; |
| 21 | 21 | import { getAppByRepo } from "../core/apps.ts"; |
| 22 | 22 | import { canEnterApp, canSeeOwnerFacts, onCrew, type Repo } from "../core/repos.ts"; |
| @@ -28,7 +28,7 @@ import { | ||
| 28 | 28 | type Run, |
| 29 | 29 | setRunErrorLine, |
| 30 | 30 | } from "../core/runs.ts"; |
| 31 | import type { User } from "../core/users.ts"; | |
| 31 | import { getUsersByIds, type User } from "../core/users.ts"; | |
| 32 | 32 | import { abortAgentRun } from "../crew/running.ts"; |
| 33 | 33 | import { queueAgentRun, taskOf } from "../crew/dispatch.ts"; |
| 34 | 34 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -169,6 +169,40 @@ export function listRepoRuns( | ||
| 169 | 169 | ); |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | /** A run as the runs API lists it: the agent's name, and the pull or issue it is about. */ | |
| 173 | export type NamedRun = Run & { | |
| 174 | readonly agent: string | null; | |
| 175 | readonly number: number | null; | |
| 176 | readonly title: string | null; | |
| 177 | }; | |
| 178 | ||
| 179 | /** | |
| 180 | * Names runs already read for this reader (`listRepoRuns`) in two grouped | |
| 181 | * reads: `agent` is the username behind an agent run (null for a check or | |
| 182 | * deploy), `number` and `title` the pull or issue the run is about (null when | |
| 183 | * it is about none). A sealed issue never reaches here: `listRepoRuns` drops | |
| 184 | * its runs for a reader who may not see it. | |
| 185 | */ | |
| 186 | export function nameRuns(app: App, runs: readonly Run[]): NamedRun[] { | |
| 187 | const actors = getUsersByIds( | |
| 188 | app.db, | |
| 189 | runs.flatMap((r) => (r.kind === "agent" && r.actorId !== null ? [r.actorId] : [])), | |
| 190 | ); | |
| 191 | const issues = getIssuesByIds( | |
| 192 | app.db, | |
| 193 | runs.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), | |
| 194 | ); | |
| 195 | return runs.map((r) => { | |
| 196 | const issue = r.issueId === null ? undefined : issues.get(r.issueId); | |
| 197 | return { | |
| 198 | ...r, | |
| 199 | agent: r.kind === "agent" && r.actorId !== null ? (actors.get(r.actorId)?.username ?? null) : null, | |
| 200 | number: issue?.number ?? null, | |
| 201 | title: issue?.title ?? null, | |
| 202 | }; | |
| 203 | }); | |
| 204 | } | |
| 205 | ||
| 172 | 206 | /** |
| 173 | 207 | * The default branch's check that covers one commit (`checks/commit_check.ts` |
| 174 | 208 | * `releaseCheck`), for whoever may read the repository's runs: what a |
src/cli/api.ts +11
| @@ -324,6 +324,17 @@ export class ForgeApi { | ||
| 324 | 324 | return ok({ ...first, items, next_cursor: cursor }); |
| 325 | 325 | } |
| 326 | 326 | |
| 327 | /** The latest runs of one repository, newest first, in the API's own shape. */ | |
| 328 | async runs( | |
| 329 | owner: string, | |
| 330 | name: string, | |
| 331 | limit: number, | |
| 332 | ): Promise<ApiResult<{ items: unknown[]; next_cursor: unknown }>> { | |
| 333 | return this.call( | |
| 334 | `/api/v1/repos/${encode(owner)}/${encode(name)}/runs?limit=${limit}`, | |
| 335 | ); | |
| 336 | } | |
| 337 | ||
| 327 | 338 | /** |
| 328 | 339 | * What waits on the user, most consequential first (`GET /api/v1/asks`). |
| 329 | 340 | * A row of a kind this client has never heard of is kept, not dropped: it |
src/cli/commands/index.ts +2
| @@ -13,6 +13,7 @@ import { command as pin } from "./pin.ts"; | ||
| 13 | 13 | import { command as unpin } from "./unpin.ts"; |
| 14 | 14 | import { command as open } from "./open.ts"; |
| 15 | 15 | import { command as pulls } from "./pulls.ts"; |
| 16 | import { command as runs } from "./runs.ts"; | |
| 16 | 17 | import { command as asks } from "./asks.ts"; |
| 17 | 18 | import { command as search } from "./search.ts"; |
| 18 | 19 | import { command as ship } from "./ship.ts"; |
| @@ -30,6 +31,7 @@ export const commands: readonly Command[] = [ | ||
| 30 | 31 | unpin, |
| 31 | 32 | open, |
| 32 | 33 | pulls, |
| 34 | runs, | |
| 33 | 35 | asks, |
| 34 | 36 | search, |
| 35 | 37 | ship, |
src/cli/commands/runs.ts added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | import { ForgeApi, type ApiError } from "../api.ts"; | |
| 2 | import { parseRepoRef, type Args } from "../args.ts"; | |
| 3 | import { requireConfig } from "../context.ts"; | |
| 4 | import { EXIT } from "../exit.ts"; | |
| 5 | import { folderRepo } from "../folder-repo.ts"; | |
| 6 | import { inert, type Out } from "../out.ts"; | |
| 7 | import { since } from "../report.ts"; | |
| 8 | import { displayWidth, pad, truncate } from "../../tui/term/width.ts"; | |
| 9 | import type { Command } from "./types.ts"; | |
| 10 | ||
| 11 | const RUN_ROWS = 10; | |
| 12 | ||
| 13 | /** Colour of a run's state word. */ | |
| 14 | const RUN_TONE: Record<string, "success" | "danger" | "attn" | "faint"> = { | |
| 15 | ok: "success", | |
| 16 | failed: "danger", | |
| 17 | running: "attn", | |
| 18 | queued: "faint", | |
| 19 | cancelled: "faint", | |
| 20 | }; | |
| 21 | ||
| 22 | /** | |
| 23 | * One run as a line's parts, from the fields the runs API names (`nameRuns`): | |
| 24 | * `agent` (blank for a check or deploy), `number` and `title` of the pull or | |
| 25 | * issue (blank when it is about none). Nothing is read out of the branch. | |
| 26 | */ | |
| 27 | export function runParts(run: Record<string, unknown>): { | |
| 28 | state: string; | |
| 29 | agent: string; | |
| 30 | number: string; | |
| 31 | title: string; | |
| 32 | when: string; | |
| 33 | cost: string; | |
| 34 | } { | |
| 35 | const text = (v: unknown): string => (typeof v === "string" ? v : ""); | |
| 36 | const number = run["number"]; | |
| 37 | const stamp = text(run["finishedAt"]) || text(run["startedAt"]) || text(run["createdAt"]); | |
| 38 | const at = Date.parse(stamp); | |
| 39 | const cost = typeof run["costUsd"] === "number" ? run["costUsd"] : 0; | |
| 40 | return { | |
| 41 | state: inert(text(run["state"])), | |
| 42 | agent: inert(text(run["agent"])), | |
| 43 | number: Number.isSafeInteger(number) && (number as number) > 0 ? `#${number}` : "", | |
| 44 | title: inert(text(run["title"]).split("\n")[0] ?? ""), | |
| 45 | when: Number.isNaN(at) ? "" : since(new Date(at)), | |
| 46 | cost: cost > 0 ? `$${cost.toFixed(2)}` : "", | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | async function runs(out: Out, args: Args): Promise<number> { | |
| 51 | const loaded = await requireConfig(out); | |
| 52 | if (typeof loaded === "number") return loaded; | |
| 53 | const flagged = args.value("repo"); | |
| 54 | const ref = | |
| 55 | flagged !== undefined | |
| 56 | ? parseRepoRef(flagged) | |
| 57 | : await folderRepo(process.cwd(), loaded.url); | |
| 58 | if (ref === undefined) { | |
| 59 | out.err( | |
| 60 | flagged !== undefined | |
| 61 | ? "forge runs: --repo expects owner/name" | |
| 62 | : `forge runs: this folder is not a clone of ${loaded.url} — run it in a clone, or pass --repo owner/name`, | |
| 63 | ); | |
| 64 | return EXIT.usage; | |
| 65 | } | |
| 66 | const got = await new ForgeApi(loaded.url, loaded.token).runs( | |
| 67 | ref.owner, | |
| 68 | ref.name, | |
| 69 | RUN_ROWS, | |
| 70 | ); | |
| 71 | if (!got.ok) return runsError(out, got.error); | |
| 72 | const listed: unknown = (got.value as { items?: unknown } | null)?.items; | |
| 73 | if ( | |
| 74 | !Array.isArray(listed) || | |
| 75 | listed.some((r) => typeof r !== "object" || r === null || Array.isArray(r)) | |
| 76 | ) { | |
| 77 | out.err("forge runs: unexpected answer"); | |
| 78 | return EXIT.failed; | |
| 79 | } | |
| 80 | if (args.flag("json")) { | |
| 81 | out.line(JSON.stringify(got.value)); | |
| 82 | return EXIT.ok; | |
| 83 | } | |
| 84 | const rows = (listed as Record<string, unknown>[]) | |
| 85 | .slice(0, RUN_ROWS) | |
| 86 | .map((r) => ({ tone: String(r["state"]), ...runParts(r) })); | |
| 87 | if (rows.length === 0) { | |
| 88 | out.line("No runs yet."); | |
| 89 | return EXIT.ok; | |
| 90 | } | |
| 91 | const wide = (pick: (r: (typeof rows)[number]) => string): number => | |
| 92 | Math.max(...rows.map((r) => displayWidth(pick(r)))); | |
| 93 | const sW = wide((r) => r.state); | |
| 94 | const aW = Math.min(16, wide((r) => r.agent)); | |
| 95 | const nW = wide((r) => r.number); | |
| 96 | const wW = wide((r) => r.when); | |
| 97 | const cW = wide((r) => r.cost); | |
| 98 | const tW = Math.max(12, out.width - sW - aW - nW - wW - cW - 10); | |
| 99 | for (const r of rows) { | |
| 100 | const key = RUN_TONE[r.tone] ?? "faint"; | |
| 101 | out.line( | |
| 102 | [ | |
| 103 | out.paint(pad(r.state, sW), out.c[key]), | |
| 104 | pad(truncate(r.agent, aW), aW), | |
| 105 | pad(r.number, nW, "right"), | |
| 106 | pad(truncate(r.title, tW), tW), | |
| 107 | pad(r.when, wW, "right"), | |
| 108 | pad(r.cost, cW, "right"), | |
| 109 | ] | |
| 110 | .join(" ") | |
| 111 | .replace(/\s+$/, ""), | |
| 112 | ); | |
| 113 | } | |
| 114 | return EXIT.ok; | |
| 115 | } | |
| 116 | ||
| 117 | /** The forge's words, and the exit code its status means. */ | |
| 118 | function runsError(out: Out, error: ApiError): number { | |
| 119 | out.err(`forge runs: ${inert(error.message)}`); | |
| 120 | return error.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 121 | } | |
| 122 | ||
| 123 | export const command: Command = { | |
| 124 | name: "runs", | |
| 125 | usage: { synopsis: "forge runs", flags: "[--repo owner/name] [--json]" }, | |
| 126 | run: runs, | |
| 127 | }; |
src/http/api.ts +12 −8
| @@ -199,6 +199,7 @@ import { | ||
| 199 | 199 | checkOfCommit, |
| 200 | 200 | getRepoRun, |
| 201 | 201 | listRepoRuns, |
| 202 | nameRuns, | |
| 202 | 203 | runForReader, |
| 203 | 204 | runLog, |
| 204 | 205 | } from "../actions/runs.ts"; |
| @@ -3544,18 +3545,21 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3544 | 3545 | const kind = q.get("kind"); |
| 3545 | 3546 | const env = q.get("env"); |
| 3546 | 3547 | const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 200); |
| 3547 | const runs = timePhase(ctx.request, "runs", () => | |
| 3548 | listRepoRuns(ctx.app, ctx.auth.user, repo, { | |
| 3548 | // Already as this reader may see them (`runForReader`): a stranger's | |
| 3549 | // runs carry no cost, turns, agent session or node. Each row also names | |
| 3550 | // its agent and its pull or issue (`nameRuns`), so a client never parses | |
| 3551 | // the branch. | |
| 3552 | const runs = timePhase(ctx.request, "runs", () => { | |
| 3553 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { | |
| 3549 | 3554 | ...(kind === "deploy" || kind === "agent" ? { kind } : {}), |
| 3550 | 3555 | ...(env !== null && isEnvName(env) ? { env } : {}), |
| 3551 | 3556 | ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}), |
| 3552 | 3557 | limit, |
| 3553 | }), | |
| 3554 | ); | |
| 3555 | if (!runs.ok) return notFound(); | |
| 3556 | // Already as this reader may see them (`runForReader`): a stranger's | |
| 3557 | // runs carry no cost, turns, agent session or node. | |
| 3558 | return timePhase(ctx.request, "render", () => json(paged(runs.value, limit, (r) => r.id))); | |
| 3558 | }); | |
| 3559 | return listed.ok ? nameRuns(ctx.app, listed.value) : undefined; | |
| 3560 | }); | |
| 3561 | if (runs === undefined) return notFound(); | |
| 3562 | return timePhase(ctx.request, "render", () => json(paged(runs, limit, (r) => r.id))); | |
| 3559 | 3563 | }); |
| 3560 | 3564 | |
| 3561 | 3565 | router.add("GET", "/api/v1/repos/:owner/:name/runs/:id", (ctx) => { |
test/api-runs-named.test.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** | |
| 2 | * The runs API names each run (#7353, `nameRuns`): `agent` is the username | |
| 3 | * behind an agent run and null for a check or deploy; `number` and `title` | |
| 4 | * are the pull or issue it is about, null when it is about none. A client | |
| 5 | * (`forge runs`) prints these instead of reading them out of the branch. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createIssue } from "../src/core/issues.ts"; | |
| 14 | import { createRun } from "../src/core/runs.ts"; | |
| 15 | import { createToken } from "../src/core/tokens.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { forgeHttp, type ForgeHttp } from "../src/http/server.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let http: ForgeHttp; | |
| 25 | let token: string; | |
| 26 | ||
| 27 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 28 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 29 | return r.value; | |
| 30 | }; | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-runs-named-")); | |
| 34 | app = createApp({ dataDir, port: 0 }); | |
| 35 | http = forgeHttp(app); | |
| 36 | const alice = ok( | |
| 37 | await createUser(app.db, { | |
| 38 | origin: "seed", | |
| 39 | trusted: true, | |
| 40 | username: "alice", | |
| 41 | email: "[email protected]", | |
| 42 | password: "a-good-passphrase", | |
| 43 | adminIfFirst: true, | |
| 44 | }), | |
| 45 | ); | |
| 46 | token = createToken(app.db, alice.id, "t", "ro").token; | |
| 47 | const repo = ok(await createRepo(app, alice, { name: "shop", visibility: "public" })); | |
| 48 | const issue = ok(createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "Login fix" })); | |
| 49 | // Branches no parser would read the same way: the names come from rows. | |
| 50 | createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id, actorId: alice.id, branch: "feature/x" }); | |
| 51 | createRun(app.db, { repoId: repo.id, kind: "check", issueId: issue.id, actorId: alice.id, env: "production" }); | |
| 52 | createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production", actorId: alice.id }); | |
| 53 | }, 60_000); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | app?.close(); | |
| 57 | await rm(dataDir, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | test("each run carries its agent, number and title", async () => { | |
| 61 | const res = await http.handle( | |
| 62 | new Request("http://forge.test/api/v1/repos/alice/shop/runs", { | |
| 63 | headers: { authorization: `Bearer ${token}` }, | |
| 64 | }), | |
| 65 | ); | |
| 66 | expect(res.status).toBe(200); | |
| 67 | const { items } = (await res.json()) as { items: Record<string, unknown>[] }; | |
| 68 | const by = (kind: string) => items.find((r) => r["kind"] === kind)!; | |
| 69 | expect(by("agent")).toMatchObject({ agent: "alice", number: 1, title: "Login fix" }); | |
| 70 | // A check names its pull or issue but no agent: whoever triggered it is not one. | |
| 71 | expect(by("check")).toMatchObject({ agent: null, number: 1, title: "Login fix" }); | |
| 72 | expect(by("deploy")).toMatchObject({ agent: null, number: null, title: null }); | |
| 73 | }); |
test/cli-runs.test.ts added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | import { afterEach, expect, test } from "bun:test"; | |
| 2 | import { EXIT, main } from "../src/cli/main.ts"; | |
| 3 | import { createOut } from "../src/cli/out.ts"; | |
| 4 | ||
| 5 | let server: ReturnType<typeof Bun.serve> | undefined; | |
| 6 | const env = { url: process.env["FORGE_URL"], token: process.env["FORGE_TOKEN"] }; | |
| 7 | afterEach(() => { | |
| 8 | server?.stop(true); | |
| 9 | for (const [k, v] of [["FORGE_URL", env.url], ["FORGE_TOKEN", env.token]] as const) | |
| 10 | if (v === undefined) delete process.env[k]; | |
| 11 | else process.env[k] = v; | |
| 12 | }); | |
| 13 | ||
| 14 | const seen: string[] = []; | |
| 15 | function fake(items: unknown[], status = 200): void { | |
| 16 | seen.length = 0; | |
| 17 | server = Bun.serve({ | |
| 18 | port: 0, | |
| 19 | fetch(req) { | |
| 20 | const url = new URL(req.url); | |
| 21 | seen.push(url.pathname + url.search); | |
| 22 | if (status === 401) | |
| 23 | return Response.json({ error: "unauthorized", message: "sign in first" }, { status }); | |
| 24 | return url.pathname === "/api/v1/repos/acme/app/runs" | |
| 25 | ? Response.json({ items, next_cursor: null }) | |
| 26 | : Response.json({ error: "not_found", message: "no such resource here" }, { status: 404 }); | |
| 27 | }, | |
| 28 | }); | |
| 29 | process.env["FORGE_URL"] = `http://127.0.0.1:${server.port}`; | |
| 30 | process.env["FORGE_TOKEN"] = "t"; | |
| 31 | } | |
| 32 | ||
| 33 | async function run(argv: string[]) { | |
| 34 | const lines: string[] = []; | |
| 35 | const errs: string[] = []; | |
| 36 | const write = process.stdout.write.bind(process.stdout); | |
| 37 | const werr = process.stderr.write.bind(process.stderr); | |
| 38 | process.stdout.write = ((s: string) => (lines.push(String(s)), true)) as never; | |
| 39 | process.stderr.write = ((s: string) => (errs.push(String(s)), true)) as never; | |
| 40 | try { | |
| 41 | const code = await main(argv); | |
| 42 | return { code, text: lines.join(""), out: lines.join("").split("\n").filter(Boolean), err: errs.join("") }; | |
| 43 | } finally { | |
| 44 | process.stdout.write = write; | |
| 45 | process.stderr.write = werr; | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); | |
| 50 | // The branch and env say nothing the columns print: agent, number and title | |
| 51 | // are the API's own fields (`nameRuns`), whatever the branch looks like. | |
| 52 | const items = [ | |
| 53 | { id: "a", kind: "agent", agent: "scout", number: 6038, title: "Login fix", branch: "feature/x", state: "running", summary: "", costUsd: 0.5, createdAt: ago(60_000), startedAt: ago(50_000), finishedAt: null }, | |
| 54 | { id: "b", kind: "check", agent: null, number: 7696, title: "Tidy the header", env: "production", branch: "", state: "failed", summary: "3 tests failed", costUsd: 0, createdAt: ago(7_200_000), startedAt: null, finishedAt: null }, | |
| 55 | ]; | |
| 56 | ||
| 57 | test("lists state, agent, number, title, age and cost, uncoloured when piped", async () => { | |
| 58 | fake(items); | |
| 59 | const r = await run(["runs", "--repo", "acme/app"]); | |
| 60 | expect(r.code).toBe(EXIT.ok); | |
| 61 | expect(r.out).toHaveLength(2); | |
| 62 | expect(r.out[0]).toMatch(/^running +scout +#6038 +Login fix +(?:now|\d+m) +\$0\.50$/); | |
| 63 | expect(r.out[1]).toMatch(/^failed +#7696 +Tidy the header +2h$/); | |
| 64 | expect(r.text).not.toContain("\x1b"); | |
| 65 | expect(seen[0]).toContain("limit=10"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("--json prints the API's answer unchanged", async () => { | |
| 69 | fake(items); | |
| 70 | const r = await run(["runs", "--repo", "acme/app", "--json"]); | |
| 71 | expect(JSON.parse(r.text)).toEqual({ items, next_cursor: null }); | |
| 72 | }); | |
| 73 | ||
| 74 | test("an unknown repository prints the API's words and fails", async () => { | |
| 75 | fake(items); | |
| 76 | const r = await run(["runs", "--repo", "acme/none"]); | |
| 77 | expect(r.code).toBe(EXIT.failed); | |
| 78 | expect(r.err).toContain("no such resource here"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("not logged in exits not-configured", async () => { | |
| 82 | fake(items, 401); | |
| 83 | const r = await run(["runs", "--repo", "acme/app"]); | |
| 84 | expect(r.code).toBe(EXIT.notConfigured); | |
| 85 | expect(r.err).toContain("sign in first"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a bad --repo is a usage error", async () => { | |
| 89 | fake(items); | |
| 90 | expect((await run(["runs", "--repo", "nope"])).code).toBe(EXIT.usage); | |
| 91 | }); | |
| 92 | ||
| 93 | test("an empty list says so", async () => { | |
| 94 | fake([]); | |
| 95 | const r = await run(["runs", "--repo", "acme/app"]); | |
| 96 | expect(r.code).toBe(EXIT.ok); | |
| 97 | expect(r.out).toEqual(["No runs yet."]); | |
| 98 | }); | |
| 99 | ||
| 100 | test("an answer that is not a list of runs is one line, not a stack trace", async () => { | |
| 101 | for (const bad of [{ items: "oops" }, { items: [1] }, { items: [null] }, {}]) { | |
| 102 | seen.length = 0; | |
| 103 | server?.stop(true); | |
| 104 | server = Bun.serve({ port: 0, fetch: () => Response.json(bad) }); | |
| 105 | process.env["FORGE_URL"] = `http://127.0.0.1:${server.port}`; | |
| 106 | process.env["FORGE_TOKEN"] = "t"; | |
| 107 | const r = await run(["runs", "--repo", "acme/app"]); | |
| 108 | expect(r.code).toBe(EXIT.failed); | |
| 109 | expect(r.err).toBe("forge runs: unexpected answer\n"); | |
| 110 | } | |
| 111 | }); | |
| 112 | ||
| 113 | test("queued and cancelled rows age from createdAt and print without colour", async () => { | |
| 114 | fake([ | |
| 115 | { id: "q", kind: "agent", agent: "mason", number: 12, title: "Add thing", branch: "mason/12-add-thing", state: "queued", summary: "", costUsd: 0, createdAt: ago(3 * 3_600_000), startedAt: null, finishedAt: null }, | |
| 116 | { id: "c", kind: "deploy", agent: null, number: null, title: null, env: "pull-9", branch: "", state: "cancelled", summary: "stopped", costUsd: 0, createdAt: ago(86_400_000 * 2), startedAt: ago(86_400_000 * 2), finishedAt: ago(5 * 60_000) }, | |
| 117 | ]); | |
| 118 | const r = await run(["runs", "--repo", "acme/app"]); | |
| 119 | expect(r.out[0]).toMatch(/^queued +mason +#12 +Add thing +3h$/); | |
| 120 | // A run about no pull or issue prints no number and no title, never a guess. | |
| 121 | expect(r.out[1]).toMatch(/^cancelled +5m$/); | |
| 122 | }); | |
| 123 | ||
| 124 | test("on a terminal the state word wears its tone", async () => { | |
| 125 | fake(items); | |
| 126 | const saved = { isTTY: process.stdout.isTTY, noColor: process.env["NO_COLOR"], forge: process.env["FORGE_COLOR"] }; | |
| 127 | Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); | |
| 128 | delete process.env["NO_COLOR"]; | |
| 129 | delete process.env["FORGE_COLOR"]; | |
| 130 | try { | |
| 131 | const o = createOut(); | |
| 132 | const r = await run(["runs", "--repo", "acme/app"]); | |
| 133 | expect(r.out[0]!.startsWith(o.paint("running", o.c.attn))).toBe(true); | |
| 134 | expect(r.out[1]!.startsWith(o.paint("failed ", o.c.danger))).toBe(true); | |
| 135 | expect(o.c.attn).not.toEqual(o.c.danger); | |
| 136 | } finally { | |
| 137 | Object.defineProperty(process.stdout, "isTTY", { value: saved.isTTY, configurable: true }); | |
| 138 | if (saved.noColor !== undefined) process.env["NO_COLOR"] = saved.noColor; | |
| 139 | if (saved.forge !== undefined) process.env["FORGE_COLOR"] = saved.forge; | |
| 140 | } | |
| 141 | }); | |
| 142 | ||
| 143 | test("without --repo the folder's origin names the repository", async () => { | |
| 144 | fake(items); | |
| 145 | const dir = await Bun.$`mktemp -d ${process.env["TMPDIR"] ?? "/tmp"}/runs-XXXXXX`.text(); | |
| 146 | const here = process.cwd(); | |
| 147 | try { | |
| 148 | await Bun.$`git init -q ${dir.trim()}`; | |
| 149 | await Bun.$`git -C ${dir.trim()} remote add origin ${process.env["FORGE_URL"]}/acme/app.git`; | |
| 150 | process.chdir(dir.trim()); | |
| 151 | const r = await run(["runs"]); | |
| 152 | expect(r.code).toBe(EXIT.ok); | |
| 153 | expect(seen[0]).toContain("/repos/acme/app/runs"); | |
| 154 | } finally { | |
| 155 | process.chdir(here); | |
| 156 | } | |
| 157 | }); | |
| 158 | ||
| 159 | test("without --repo outside a clone is a usage error", async () => { | |
| 160 | fake(items); | |
| 161 | const dir = (await Bun.$`mktemp -d ${process.env["TMPDIR"] ?? "/tmp"}/runs-XXXXXX`.text()).trim(); | |
| 162 | const here = process.cwd(); | |
| 163 | try { | |
| 164 | process.chdir(dir); | |
| 165 | const r = await run(["runs"]); | |
| 166 | expect(r.code).toBe(EXIT.usage); | |
| 167 | expect(r.err).toContain("not a clone"); | |
| 168 | expect(seen).toHaveLength(0); | |
| 169 | } finally { | |
| 170 | process.chdir(here); | |
| 171 | } | |
| 172 | }); |
new above
offline
Add tests: empty list prints
No runs yet.; no --repo uses the folder's origin (and exits 2 outside a clone); queued/cancelled rows.