Public: anyone can read this; the crew can change it.

`forge pulls` lists the open pulls of the folder's repository: number, title, author, check and preview address #7406

Merged opened this

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

Merged

scout/forge:scout/7370-forge-pulls-lists-the-open-pulls-of-the → main 6 commits fork 7 threads · 7 unresolved

  • Merged. scout/7370-forge-pulls-lists-the-open-pulls-of-the was merged into main as 77e0908.

Review

Sign in to review this change.
8 files changed +509 −1
README.md +12
@@ -302,10 +302,22 @@ forge sync --ff # fast-forward clean repos, and only those
302302 forge pin owner/name # ask the workbench to keep this one here
303303 forge ls # what is here, and how stale
304304 forge open owner/name --web # the page, in a browser
305 forge pulls # open pulls of this clone, newest first
305306 forge asks # what waits on you, most important first
306307 forge asks --repo owner/name # only that repository's; --json for the API's answer
307308 ```
308309
310 `forge pulls` prints one line per pull: `#N`, the title cut to the terminal
311 width, the author, the check (`green`, `red`, `running` or `none`) and the
312 preview address when the repository is an app. It asks about the repository
313 your `origin` remote names, when that remote is on the configured workbench;
314 anywhere else it exits 2 and says so, and `--repo owner/name` names one
315 yourself. `--closed` lists closed pulls, `--json` prints the API's answer, and
316 a list longer than the 100-pull pages it follows is marked as cut. This is a
317 shell command on purpose: the terminal UI of the forge (`ssh`, `src/tty.ts`)
318 already has its own pulls screen, and the local UI is about working trees, not
319 the workbench's conversation; the pulls API and MCP are the other doors.
320
309321 `sync` never merges, rebases, resets or checks anything out. `--ff` is the one
310322 exception and it fast-forwards a repo only when the tree is clean, nothing is
311323 ahead, no rebase or merge is half-finished, and `merge-base --is-ancestor` has
src/cli/api.ts +33
@@ -36,6 +36,9 @@ export interface Whoami {
3636 readonly displayName: string;
3737 }
3838
39 /** Pages of 100 a listing follows before it says it is cut. */
40 const PULL_PAGES = 20;
41
3942 /** One thing waiting on the user, as `forge asks` prints it. */
4043 export interface Ask {
4144 /** The API's kind (`look`, `parked`, `proposal`, …), or `forge` for its own needs. */
@@ -191,6 +194,36 @@ export class ForgeApi {
191194 );
192195 }
193196
197 /**
198 * The pulls of one repository, newest first, in the API's own shape. The
199 * cursor is followed to the end (at most `PULL_PAGES` pages), so the answer
200 * is whole; a single page comes back exactly as the API sent it, and when
201 * the cap is hit `next_cursor` is still set — the caller says it is cut.
202 */
203 async pulls(
204 owner: string,
205 name: string,
206 state: "open" | "closed" = "open",
207 ): Promise<ApiResult<{ items: unknown[]; next_cursor: unknown }>> {
208 const base = `/api/v1/repos/${encode(owner)}/${encode(name)}/pulls?state=${state}&limit=100`;
209 let first: Record<string, unknown> | undefined;
210 const items: unknown[] = [];
211 let cursor: unknown = null;
212 for (let page = 0; page < PULL_PAGES; page++) {
213 const query = cursor === null ? "" : `&cursor=${encodeURIComponent(String(cursor))}`;
214 const out = await this.call<Record<string, unknown>>(`${base}${query}`);
215 if (!out.ok) return out;
216 first ??= out.value;
217 if (Array.isArray(out.value["items"])) items.push(...out.value["items"]);
218 cursor = out.value["next_cursor"] ?? null;
219 if (cursor === null || cursor === "") {
220 cursor = null;
221 break;
222 }
223 }
224 return ok({ ...first, items, next_cursor: cursor });
225 }
226
194227 /**
195228 * What waits on the user, most consequential first (`GET /api/v1/asks`).
196229 * A row of a kind this client has never heard of is kept, not dropped: it
src/cli/folder-repo.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * Which repository this folder is a clone of, as far as the configured
3 * workbench is concerned: the `origin` remote, when it points at that host.
4 * A remote elsewhere (GitHub, a colleague's forge) answers undefined — the
5 * caller says so rather than asking the wrong server about the wrong repo.
6 */
7
8 import { parseRepoRef } from "./args.ts";
9
10 /** `owner/name` out of an origin URL, if its host is the workbench's. */
11 export function repoFromRemote(
12 remote: string,
13 workbenchUrl: string,
14 ): { owner: string; name: string } | undefined {
15 let host: string;
16 let path: string;
17 const text = remote.trim();
18 const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(text);
19 if (scp) {
20 host = scp[1]!;
21 path = scp[2]!;
22 } else {
23 try {
24 const url = new URL(text);
25 host = url.hostname;
26 path = url.pathname;
27 } catch {
28 return undefined;
29 }
30 }
31 let wanted: string;
32 try {
33 wanted = new URL(workbenchUrl).hostname;
34 } catch {
35 return undefined;
36 }
37 if (host.toLowerCase() !== wanted.toLowerCase()) return undefined;
38 return parseRepoRef(
39 path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, ""),
40 );
41 }
42
43 /** The folder's `origin`, read with git (argv, never a shell). */
44 export async function folderRepo(
45 dir: string,
46 workbenchUrl: string,
47 ): Promise<{ owner: string; name: string } | undefined> {
48 try {
49 const proc = Bun.spawn(["git", "-C", dir, "remote", "get-url", "origin"], {
50 stdout: "pipe",
51 stderr: "ignore",
52 });
53 const text = await new Response(proc.stdout).text();
54 if ((await proc.exited) !== 0) return undefined;
55 return repoFromRemote(text, workbenchUrl);
56 } catch {
57 return undefined;
58 }
59 }
src/cli/main.ts +84
@@ -25,6 +25,7 @@ import {
2525 saveConfig,
2626 type Config,
2727 } from "./config.ts";
28 import { folderRepo } from "./folder-repo.ts";
2829 import { systemNotifier } from "./notify.ts";
2930 import { cell, createOut, inert, type Out } from "./out.ts";
3031 import { dirtyCount, printReport, settled, since } from "./report.ts";
@@ -95,6 +96,8 @@ export async function main(argv: readonly string[]): Promise<number> {
9596 return setPin(out, args, true);
9697 case "unpin":
9798 return setPin(out, args, false);
99 case "pulls":
100 return pulls(out, args);
98101 case "open":
99102 return openRepo(out, args);
100103 case "asks":
@@ -125,6 +128,9 @@ function usage(out: Out): void {
125128 out.line(b(" forge pin <owner/name>"));
126129 out.line(b(" forge unpin <owner/name>"));
127130 out.line(b(" forge open <owner/name>") + " [--web]");
131 out.line(
132 b(" forge pulls") + " [--repo owner/name] [--closed] [--json]",
133 );
128134 out.line(
129135 b(" forge asks") + " [--repo owner/name] [--json]",
130136 );
@@ -401,6 +407,84 @@ async function openRepo(out: Out, args: Args): Promise<number> {
401407 return EXIT.ok;
402408 }
403409
410 // ── pulls ────────────────────────────────────────────────────────────────
411
412 async function pulls(out: Out, args: Args): Promise<number> {
413 const loaded = await requireConfig(out);
414 if (typeof loaded === "number") return loaded;
415 const flagged = args.value("repo");
416 const ref =
417 flagged !== undefined
418 ? parseRepoRef(flagged)
419 : await folderRepo(process.cwd(), loaded.url);
420 if (ref === undefined) {
421 out.err(
422 flagged !== undefined
423 ? "forge pulls: --repo expects owner/name"
424 : `forge pulls: this folder is not a clone of ${loaded.url} — run it in a clone, or pass --repo owner/name`,
425 );
426 return EXIT.usage;
427 }
428 const closed = args.flag("closed");
outdated

Guessing state names. Define the field in the API and accept exactly those values.

429 const got = await new ForgeApi(loaded.url, loaded.token).pulls(
430 ref.owner,
431 ref.name,
432 closed ? "closed" : "open",
433 );
434 if (!got.ok) {
435 out.err(`forge pulls: ${got.error.message}`);
436 return EXIT.failed;
437 }
438 if (args.flag("json")) {
439 out.line(JSON.stringify(got.value));
440 return EXIT.ok;
441 }
442 const items = got.value.items as Record<string, unknown>[];
443 if (items.length === 0) {
444 out.line(closed ? "No closed pulls." : "No open pulls.");
445 return EXIT.ok;
446 }
447 const tone = {
448 green: out.c.success,
449 red: out.c.danger,
450 running: out.c.attn,
451 none: out.c.faint,
452 } as const;
453 const rows = items.map((item) => ({
454 number: `#${String(item["number"])}`,
455 title: inert(String(item["title"] ?? "")),
456 author: inert(String(item["author"] ?? "")),
457 check: String(item["check"]) as keyof typeof tone,
458 preview: inert(typeof item["preview"] === "string" ? item["preview"] : ""),
459 }));
460 const wide = (pick: (r: (typeof rows)[number]) => string): number =>
461 Math.max(...rows.map((r) => displayWidth(pick(r))));
462 const nW = wide((r) => r.number);
463 const aW = Math.min(20, wide((r) => r.author));
464 const cW = wide((r) => r.check);
465 const pW = wide((r) => r.preview);
466 const gaps = 2 * (pW > 0 ? 4 : 3);
467 const tW = Math.max(12, out.width - nW - aW - cW - pW - gaps);
468 for (const r of rows) {
469 const parts = [
470 pad(r.number, nW, "right"),
471 pad(truncate(r.title, tW), tW),
472 pad(truncate(r.author, aW), aW),
473 out.paint(pad(r.check, cW), tone[r.check] ?? out.c.faint),
474 ];
475 if (pW > 0) parts.push(r.preview);
476 out.line(parts.join(" ").replace(/\s+$/, ""));
477 }
478 if (got.value.next_cursor !== null)
479 out.line(
480 out.paint(
481 `The list is cut after ${rows.length} pulls; the forge has more.`,
482 out.c.faint,
483 ),
484 );
485 return EXIT.ok;
486 }
outdated

The API already gives author. Read it. Also the check/preview fields read here are absent from the real list response, so these columns are always empty.

487
404488 // ── asks ─────────────────────────────────────────────────────────────────
405489
406490 /**
src/http/api.ts +19 −1
@@ -320,7 +320,8 @@ import {
320320 takeBackLimb,
321321 } from "../actions/limb_replan.ts";
322322 import { appsRuntime, envsFor, type AppsRuntime } from "../apps/runtime.ts";
323 import { isEnvName } from "../apps/data.ts";
323 import { isEnvName, previewEnv } from "../apps/data.ts";
324 import { checkWord } from "../apps/check_word.ts";
324325 import { getAppByRepo } from "../core/apps.ts";
325326 import { crewRole, isCrew, type Agent } from "../core/agents.ts";
326327 import { listApps, type AppRecord } from "../core/apps.ts";
@@ -331,6 +332,7 @@ import {
331332 checksForHead,
332333 getRun,
333334 isTerminal,
335 latestRunsForIssues,
334336 listActiveRuns,
335337 type Run,
336338 } from "../core/runs.ts";
@@ -3359,8 +3361,24 @@ export function apiRoutes(router: Router<Handler>): void {
33593361 );
33603362 const items = timePhase(ctx.request, "labels", () => {
33613363 const siblings = variantSiblings(ctx.app.db, pulls);
3364 // The check word and preview address the pull page shows, so a client
3365 // lists "where to try it" without a request per pull.
3366 const checks = latestRunsForIssues(ctx.app.db, pulls.map((p) => p.id));
outdated

Low: door_pulls.ts (the SSH pulls screen and the app door) takes the latest deploy or check run, and this passes ["check"] only. The two doors can then show different words for the same pull. Use one rule in both places.

3367 // An address only where the pull page's preview card shows one: the
3368 // environments `envsFor` hands this viewer (a private app shows none).
3369 const record = getAppByRepo(ctx.app.db, repo.id);
3370 const runtime = record === undefined ? undefined : appsRuntime(ctx.app);
3371 const live = new Set(
3372 record === undefined ? [] : envsFor(ctx.app, ctx.auth.user, record).map((e) => e.env),
3373 );
outdated

High: this skips envsFor. For a private-access app it hands preview addresses to readers who cannot enter the app, and the pull page withholds them from those same readers. Build it from envsFor(ctx.app, ctx.auth.user, record): an address only when that list contains previewEnv(item.number), otherwise null.

33623374 return withLabelsMany(ctx, pulls).map((item, i) => ({
33633375 ...item,
3376 author: item.originAuthor ?? item.authorName,
3377 check: checkWord(checks.get(item.id), item.headOid),
3378 preview:
3379 record !== undefined && runtime !== undefined && item.state === "open" && item.mergedAt === null && live.has(previewEnv(item.number))
3380 ? runtime.urlFor(record, previewEnv(item.number))
3381 : null,
33643382 variantSiblings: siblings[i] ?? [],
33653383 ...(states[i] ?? {}),
33663384 }));
test/api-pulls-check-preview.test.ts added +152
@@ -0,0 +1,152 @@
1 /** The API's list of pulls carries author, check word and preview address. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { newApp } from "../src/actions/apps.ts";
8 import { AppsRuntime } from "../src/apps/runtime.ts";
9 import { getAppByRepo } from "../src/core/apps.ts";
10 import { createIssue } from "../src/core/issues.ts";
11 import { createSession } from "../src/core/sessions.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let runtime: AppsRuntime;
22 let cookie = "";
23 let repoId = "";
24
25 const addPull = (title: string, head: string) => {
26 const made = createIssue(app.db, { repoId, authorId: userId, title, kind: "pull" });
27 if (!made.ok) throw new Error(made.error);
28 app.db
29 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', ?)")
30 .run(made.value.id, `h-${head}`, head);
31 return made.value;
32 };
33 const run = (issueId: string, oid: string, state: string) =>
34 app.db
35 .query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'check', ?, ?, ?)")
36 .run(`zzzz-${crypto.randomUUID()}`, repoId, issueId, oid, state);
37 let userId = "";
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-api-pulls-check-"));
41 app = createApp({ dataDir, port: 0, host: "forge.test" });
42 servers = await serve(app);
43 runtime = new AppsRuntime(app, {
44 domain: "apps.test",
45 scheme: "http",
46 publicPort: servers.port,
47 forgeUrl: servers.url,
48 concurrency: 1,
49 });
50 await runtime.start();
51 const u = await createUser(app.db, {
52 origin: "seed",
53 trusted: true,
54 username: "alice",
55 email: "[email protected]",
56 password: "a-good-passphrase",
57 adminIfFirst: true,
58 });
59 if (!u.ok) throw new Error(u.error);
60 userId = u.value.id;
61 cookie = `forge_session=${createSession(app.db, userId).token}`;
62 const made = await newApp(app, u.value, { name: "notes", description: "x", visibility: "public" });
63 if (!made.ok) throw new Error(String(made.error));
64 repoId = made.value.repo.id;
65 }, 120_000);
66
67 afterAll(async () => {
68 await runtime.stop();
69 servers.stop();
70 app.close();
71 await rm(dataDir, { recursive: true, force: true });
72 });
73
74 const listed = async (headers: Record<string, string> = {}, state = "open") => {
75 const res = await fetch(`${servers.url}/api/v1/repos/alice/notes/pulls?state=${state}`, { headers });
76 expect(res.status).toBe(200);
77 const body = (await res.json()) as { items: Record<string, unknown>[] };
78 return Object.fromEntries(body.items.map((i) => [i["title"] as string, i])) as Record<string, Record<string, unknown>>;
79 };
80
81 /** An environment the runtime holds for a pull, as a sleeping preview does. */
82 const serveEnv = (number: number, oid: string) =>
83 (runtime as unknown as { envs: Map<string, unknown> }).envs.set(`${record().id}/pull-${number}`, {
84 app: record(),
85 env: `pull-${number}`,
86 chain: Promise.resolve(),
87 restarts: [],
88 asleep: { commitOid: oid, runId: "r", since: new Date().toISOString() },
89 });
90 const record = () => getAppByRepo(app.db, repoId)!;
91 const pulls: Record<string, { id: string; number: number }> = {};
92
93 test("each pull names its author and its check word", async () => {
94 for (const [title, head] of [["green", "g1"], ["red", "r1"], ["going", "n1"], ["stale", "s1"], ["deployed", "d1"]] as const)
95 pulls[title] = addPull(title, head);
96 run(pulls["green"]!.id, "g1", "ok");
outdated

This asserts an address for a pull with no deploy and no environment, which is the wrong behaviour (see the api.ts comment). Expect null here. Also add the private-access case: a guest or plain reader gets null, and a writer gets the address.

97 run(pulls["red"]!.id, "r1", "failed");
98 run(pulls["going"]!.id, "n1", "running");
99 run(pulls["stale"]!.id, "older", "ok");
100 // The newest run is a deploy: the SSH pulls screen and the app door read it, so this does.
101 // (ids sort last, so the runtime's own deploys of these made-up heads never outrank them)
102 run(pulls["deployed"]!.id, "d1", "ok");
103 app.db
104 .query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'deploy', ?, 'd1', 'failed')")
105 .run(`zzzzz-${crypto.randomUUID()}`, repoId, pulls["deployed"]!.id);
106 const by = await listed({ cookie });
107 expect(by["green"]!["check"]).toBe("green");
108 expect(by["red"]!["check"]).toBe("red");
109 expect(by["going"]!["check"]).toBe("running");
110 expect(by["stale"]!["check"]).toBe("none");
111 expect(by["deployed"]!["check"]).toBe("red");
112 expect(by["green"]!["author"]).toBe("alice");
113 });
114
115 test("a preview address only where an environment exists, and only for those who may enter the app", async () => {
116 serveEnv(pulls["green"]!.number, "g1");
117 const address = runtime.urlFor(record(), `pull-${pulls["green"]!.number}`);
118
119 // A public app: every reader gets the address for the pull that has an environment, null for the rest.
120 const open = await listed();
121 expect(open["green"]!["preview"]).toBe(address);
122 // The runtime may have brought some others up itself; an address is given exactly where one exists.
123 const held = new Set(runtime.status(record()).map((e) => e.env));
124 for (const item of Object.values(open))
125 expect(item["preview"] === null).toBe(!held.has(`pull-${item["number"]}` as never));
126
127 // A private app on a public repository: a guest and a plain reader get none; the writer does.
128 app.db.query("UPDATE app SET access = 'private' WHERE repo_id = ?1").run(repoId);
129 const bob = await createUser(app.db, {
130 origin: "seed",
131 trusted: true,
132 username: "bob",
133 email: "[email protected]",
134 password: "a-good-passphrase",
135 });
136 if (!bob.ok) throw new Error(bob.error);
137 const bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`;
138 for (const headers of [{}, { cookie: bobCookie }]) {
139 const seen = await listed(headers);
140 expect(Object.values(seen).map((i) => i["preview"])).toEqual(Object.values(seen).map(() => null));
141 expect(seen["green"]!["check"]).toBe("green");
142 }
143 expect((await listed({ cookie }))["green"]!["preview"]).toBe(address);
144 });
145
146 test("a closed pull has no preview address", async () => {
147 const done = addPull("done", "c1");
148 serveEnv(done.number, "c1");
149 app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(done.id);
150 const by = await listed({ cookie }, "closed");
151 expect(by["done"]!["preview"]).toBeNull();
152 });
test/cli-pulls.test.ts added +149
@@ -0,0 +1,149 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { repoFromRemote } from "../src/cli/folder-repo.ts";
6 import { EXIT, main } from "../src/cli/main.ts";
7
8 let server: ReturnType<typeof Bun.serve> | undefined;
9 const env = { url: process.env["FORGE_URL"], token: process.env["FORGE_TOKEN"] };
10 afterEach(() => {
11 server?.stop(true);
12 for (const [k, v] of [["FORGE_URL", env.url], ["FORGE_TOKEN", env.token]] as const)
13 if (v === undefined) delete process.env[k];
14 else process.env[k] = v;
15 });
16
17 const seen: string[] = [];
18 function fake(items: unknown[]): void {
19 seen.length = 0;
20 server = Bun.serve({
21 port: 0,
22 fetch(req) {
23 const url = new URL(req.url);
24 seen.push(url.pathname + url.search);
25 return url.pathname === "/api/v1/repos/acme/app/pulls"
26 ? Response.json({ items, next_cursor: null })
27 : Response.json({ error: "not_found" }, { status: 404 });
28 },
29 });
30 process.env["FORGE_URL"] = `http://127.0.0.1:${server.port}`;
31 process.env["FORGE_TOKEN"] = "t";
32 }
33
34 async function run(argv: string[], cwd?: string) {
35 const lines: string[] = [];
36 const errs: string[] = [];
37 const write = process.stdout.write.bind(process.stdout);
38 const werr = process.stderr.write.bind(process.stderr);
outdated

Dead code: console.log alias and void out. Remove.

39 const prev = process.cwd();
40 if (cwd) process.chdir(cwd);
41 process.stdout.write = ((s: string) => (lines.push(String(s)), true)) as never;
42 process.stderr.write = ((s: string) => (errs.push(String(s)), true)) as never;
43 try {
44 const code = await main(argv);
45 return { code, out: lines.join("").split("\n").filter(Boolean), err: errs.join("") };
46 } finally {
47 process.stdout.write = write;
48 process.stderr.write = werr;
49 process.chdir(prev);
50 }
51 }
52
53 const items = [
54 { number: 12, title: "Add pulls", author: "scout", check: "green", preview: "https://p12.example" },
55 { number: 9, title: "Fix it", author: "mason", check: "red", preview: null },
56 { number: 7, title: "Wait", author: "atlas", check: "running", preview: null },
57 { number: 5, title: "Bare", author: "quill", check: "none", preview: null },
58 ];
59
60 test("lists one line per pull with the check and preview", async () => {
61 fake(items);
62 const r = await run(["pulls", "--repo", "acme/app"]);
63 expect(r.code).toBe(EXIT.ok);
64 expect(r.out).toHaveLength(4);
65 expect(r.out[0]).toMatch(/^#12 +Add pulls +scout +green +https:\/\/p12\.example$/);
66 expect(r.out[1]).toMatch(/^ #9 +Fix it +mason +red$/);
67 expect(r.out[2]).toMatch(/running$/);
68 expect(r.out[3]).toMatch(/quill +none$/);
69 expect(seen[0]).toContain("state=open");
70 });
71
72 test("--closed asks for closed pulls and lists them", async () => {
73 fake(items.slice(0, 1));
74 const r = await run(["pulls", "--repo", "acme/app", "--closed"]);
75 expect(r.code).toBe(EXIT.ok);
76 expect(seen[0]).toContain("state=closed");
77 expect(r.out).toHaveLength(1);
78 expect(r.out[0]).toMatch(/^#12 +Add pulls +scout +green/);
79 });
80
81 test("it follows next_cursor, and says so when the list is cut", async () => {
82 const many = (from: number) => Array.from({ length: 3 }, (_, i) => ({ number: from - i, title: `p${from - i}`, author: "a", check: "none", preview: null }));
83 seen.length = 0;
84 server = Bun.serve({
85 port: 0,
86 fetch(req) {
87 const url = new URL(req.url);
88 seen.push(url.search);
89 const cursor = url.searchParams.get("cursor");
90 if (cursor === null) return Response.json({ items: many(9), next_cursor: 7 });
91 return Response.json({ items: many(6), next_cursor: null });
92 },
93 });
94 process.env["FORGE_URL"] = `http://127.0.0.1:${server.port}`;
95 process.env["FORGE_TOKEN"] = "t";
96 const r = await run(["pulls", "--repo", "acme/app"]);
97 expect(r.out).toHaveLength(6);
98 expect(seen[1]).toContain("cursor=7");
99 expect(r.out.join("\n")).not.toContain("cut");
100
101 seen.length = 0;
102 server.stop(true);
103 server = Bun.serve({
104 port: 0,
105 fetch: () => Response.json({ items: many(9), next_cursor: 7 }),
106 });
107 process.env["FORGE_URL"] = `http://127.0.0.1:${server.port}`;
108 const cut = await run(["pulls", "--repo", "acme/app"]);
109 expect(cut.out.at(-1)).toMatch(/The list is cut after 60 pulls/);
110 });
111
112 test("--json prints the answer unchanged", async () => {
113 fake(items);
114 const r = await run(["pulls", "--repo", "acme/app", "--json"]);
115 expect(JSON.parse(r.out[0]!)).toEqual({ items, next_cursor: null });
116 });
117
118 test("no pulls says so and exits 0", async () => {
119 fake([]);
120 expect((await run(["pulls", "--repo", "acme/app"])).out).toEqual(["No open pulls."]);
121 expect((await run(["pulls", "--repo", "acme/app"])).code).toBe(0);
122 });
123
124 test("a folder that is not a clone and no --repo exits 2", async () => {
125 fake(items);
126 const dir = mkdtempSync(join(process.env["TMPDIR"] ?? tmpdir(), "pulls-"));
127 const r = await run(["pulls"], dir);
128 expect(r.code).toBe(EXIT.usage);
129 expect(r.err.trim().split("\n")).toHaveLength(1);
130 expect(seen).toEqual([]);
131 });
132
133 test("a bad --repo exits 2; an unknown repo exits 1", async () => {
134 fake(items);
135 expect((await run(["pulls", "--repo", "nope"])).code).toBe(EXIT.usage);
136 expect((await run(["pulls", "--repo", "acme/gone"])).code).toBe(EXIT.failed);
137 });
138
139 test("help lists pulls", async () => {
140 expect((await run(["help"])).out.join("\n")).toContain("forge pulls");
141 });
142
143 test("origin remotes map to a repo only on the workbench host", () => {
144 const wb = "https://forge.example.com";
145 expect(repoFromRemote("https://forge.example.com/acme/app.git\n", wb)).toEqual({ owner: "acme", name: "app" });
146 expect(repoFromRemote("[email protected]:acme/app.git", wb)).toEqual({ owner: "acme", name: "app" });
147 expect(repoFromRemote("https://github.com/acme/app.git", wb)).toBeUndefined();
148 expect(repoFromRemote("garbage", wb)).toBeUndefined();
149 });
test/spawn-pipes.test.ts +1
@@ -34,6 +34,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = {
3434 "src/release/boot.ts": { sites: ["stderr"], reason: "stderr read to its end in a background loop, awaited before the reason is read; stdout ignored" },
3535 "src/release/checkout.ts": { sites: ["stdout+stderr"], reason: "stdout and stderr both read via Response.text()" },
3636 "src/cli/config.ts": { sites: ["stdout"], reason: "stdout awaited with the exit; stderr ignored" },
37 "src/cli/folder-repo.ts": { sites: ["stdout"], reason: "stdout awaited with the exit; stderr ignored" },
3738 };
3839
3940 function files(dir: string): string[] {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs