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

Merged opened this

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

Merged

scout/forge:scout/7353-forge-runs-lists-what-the-crew-is-doing → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/7353-forge-runs-lists-what-the-crew-is-doing was merged into main as a720424.

Review

Sign in to review this change.
9 files changed +466 −10
README.md +10
@@ -325,6 +325,7 @@ forge ls # what is here, and how stale
325325 forge open owner/name --web # the page, in a browser
326326 forge ship 12 # land pull #12 after a y/N, as Merge does
327327 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
328329 forge asks # what waits on you, most important first
329330 forge asks --repo owner/name # only that repository's; --json for the API's answer
330331 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 —
368369 `space` stages, `enter` opens the hunks, `s` stages one, `c` commits, `P`
369370 pushes, `b` branches, `l` log, `z` stashes, `?` shows every binding.
370371
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
371381 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`).
372382
373383 Build it once and put it on your PATH:
docs/CLI.md +23
@@ -269,6 +269,29 @@ $ forge pulls --repo alice/shop
269269 #2 Dark print view carol red
270270 ```
271271
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
272295 ## forge asks
273296
274297 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";
1616 import { requireAbility } from "./roles.ts";
1717 import type { App } from "../app.ts";
1818 import { steerRun } from "../crew/runner.ts";
19 import { getIssueById } from "../core/issues.ts";
19 import { getIssueById, getIssuesByIds } from "../core/issues.ts";
2020 import { addComment } from "./issues.ts";
2121 import { getAppByRepo } from "../core/apps.ts";
2222 import { canEnterApp, canSeeOwnerFacts, onCrew, type Repo } from "../core/repos.ts";
@@ -28,7 +28,7 @@ import {
2828 type Run,
2929 setRunErrorLine,
3030 } from "../core/runs.ts";
31 import type { User } from "../core/users.ts";
31 import { getUsersByIds, type User } from "../core/users.ts";
3232 import { abortAgentRun } from "../crew/running.ts";
3333 import { queueAgentRun, taskOf } from "../crew/dispatch.ts";
3434 import { err, ok, type Result } from "../kernel/result.ts";
@@ -169,6 +169,40 @@ export function listRepoRuns(
169169 );
170170 }
171171
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
172206 /**
173207 * The default branch's check that covers one commit (`checks/commit_check.ts`
174208 * `releaseCheck`), for whoever may read the repository's runs: what a
src/cli/api.ts +11
@@ -324,6 +324,17 @@ export class ForgeApi {
324324 return ok({ ...first, items, next_cursor: cursor });
325325 }
326326
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
327338 /**
328339 * What waits on the user, most consequential first (`GET /api/v1/asks`).
329340 * 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";
1313 import { command as unpin } from "./unpin.ts";
1414 import { command as open } from "./open.ts";
1515 import { command as pulls } from "./pulls.ts";
16 import { command as runs } from "./runs.ts";
1617 import { command as asks } from "./asks.ts";
1718 import { command as search } from "./search.ts";
1819 import { command as ship } from "./ship.ts";
@@ -30,6 +31,7 @@ export const commands: readonly Command[] = [
3031 unpin,
3132 open,
3233 pulls,
34 runs,
3335 asks,
3436 search,
3537 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 {
199199 checkOfCommit,
200200 getRepoRun,
201201 listRepoRuns,
202 nameRuns,
202203 runForReader,
203204 runLog,
204205 } from "../actions/runs.ts";
@@ -3544,18 +3545,21 @@ export function apiRoutes(router: Router<Handler>): void {
35443545 const kind = q.get("kind");
35453546 const env = q.get("env");
35463547 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, {
35493554 ...(kind === "deploy" || kind === "agent" ? { kind } : {}),
35503555 ...(env !== null && isEnvName(env) ? { env } : {}),
35513556 ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}),
35523557 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)));
35593563 });
35603564
35613565 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 () => {
outdated

Add tests: empty list prints No runs yet.; no --repo uses the folder's origin (and exits 2 outside a clone); queued/cancelled rows.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs