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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7370-forge-pulls-lists-the-open-pulls-of-the → main 6 commits fork 7 threads · 7 unresolved
README.md +12
| @@ -302,10 +302,22 @@ forge sync --ff # fast-forward clean repos, and only those | ||
| 302 | 302 | forge pin owner/name # ask the workbench to keep this one here |
| 303 | 303 | forge ls # what is here, and how stale |
| 304 | 304 | forge open owner/name --web # the page, in a browser |
| 305 | forge pulls # open pulls of this clone, newest first | |
| 305 | 306 | forge asks # what waits on you, most important first |
| 306 | 307 | forge asks --repo owner/name # only that repository's; --json for the API's answer |
| 307 | 308 | |
| 308 | 309 | |
| 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 | ||
| 309 | 321 | `sync` never merges, rebases, resets or checks anything out. `--ff` is the one |
| 310 | 322 | exception and it fast-forwards a repo only when the tree is clean, nothing is |
| 311 | 323 | 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 { | ||
| 36 | 36 | readonly displayName: string; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | /** Pages of 100 a listing follows before it says it is cut. */ | |
| 40 | const PULL_PAGES = 20; | |
| 41 | ||
| 39 | 42 | /** One thing waiting on the user, as `forge asks` prints it. */ |
| 40 | 43 | export interface Ask { |
| 41 | 44 | /** The API's kind (`look`, `parked`, `proposal`, …), or `forge` for its own needs. */ |
| @@ -191,6 +194,36 @@ export class ForgeApi { | ||
| 191 | 194 | ); |
| 192 | 195 | } |
| 193 | 196 | |
| 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 | ||
| 194 | 227 | /** |
| 195 | 228 | * What waits on the user, most consequential first (`GET /api/v1/asks`). |
| 196 | 229 | * 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 { | ||
| 25 | 25 | saveConfig, |
| 26 | 26 | type Config, |
| 27 | 27 | } from "./config.ts"; |
| 28 | import { folderRepo } from "./folder-repo.ts"; | |
| 28 | 29 | import { systemNotifier } from "./notify.ts"; |
| 29 | 30 | import { cell, createOut, inert, type Out } from "./out.ts"; |
| 30 | 31 | import { dirtyCount, printReport, settled, since } from "./report.ts"; |
| @@ -95,6 +96,8 @@ export async function main(argv: readonly string[]): Promise<number> { | ||
| 95 | 96 | return setPin(out, args, true); |
| 96 | 97 | case "unpin": |
| 97 | 98 | return setPin(out, args, false); |
| 99 | case "pulls": | |
| 100 | return pulls(out, args); | |
| 98 | 101 | case "open": |
| 99 | 102 | return openRepo(out, args); |
| 100 | 103 | case "asks": |
| @@ -125,6 +128,9 @@ function usage(out: Out): void { | ||
| 125 | 128 | out.line(b(" forge pin <owner/name>")); |
| 126 | 129 | out.line(b(" forge unpin <owner/name>")); |
| 127 | 130 | out.line(b(" forge open <owner/name>") + " [--web]"); |
| 131 | out.line( | |
| 132 | b(" forge pulls") + " [--repo owner/name] [--closed] [--json]", | |
| 133 | ); | |
| 128 | 134 | out.line( |
| 129 | 135 | b(" forge asks") + " [--repo owner/name] [--json]", |
| 130 | 136 | ); |
| @@ -401,6 +407,84 @@ async function openRepo(out: Out, args: Args): Promise<number> { | ||
| 401 | 407 | return EXIT.ok; |
| 402 | 408 | } |
| 403 | 409 | |
| 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"); | |
| 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 |
||
| 487 | ||
| 404 | 488 | // ── asks ───────────────────────────────────────────────────────────────── |
| 405 | 489 | |
| 406 | 490 | /** |
src/http/api.ts +19 −1
| @@ -320,7 +320,8 @@ import { | ||
| 320 | 320 | takeBackLimb, |
| 321 | 321 | } from "../actions/limb_replan.ts"; |
| 322 | 322 | 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"; | |
| 324 | 325 | import { getAppByRepo } from "../core/apps.ts"; |
| 325 | 326 | import { crewRole, isCrew, type Agent } from "../core/agents.ts"; |
| 326 | 327 | import { listApps, type AppRecord } from "../core/apps.ts"; |
| @@ -331,6 +332,7 @@ import { | ||
| 331 | 332 | checksForHead, |
| 332 | 333 | getRun, |
| 333 | 334 | isTerminal, |
| 335 | latestRunsForIssues, | |
| 334 | 336 | listActiveRuns, |
| 335 | 337 | type Run, |
| 336 | 338 | } from "../core/runs.ts"; |
| @@ -3359,8 +3361,24 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3359 | 3361 | ); |
| 3360 | 3362 | const items = timePhase(ctx.request, "labels", () => { |
| 3361 | 3363 | 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: |
||
| 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 |
||
| 3362 | 3374 | return withLabelsMany(ctx, pulls).map((item, i) => ({ |
| 3363 | 3375 | ...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, | |
| 3364 | 3382 | variantSiblings: siblings[i] ?? [], |
| 3365 | 3383 | ...(states[i] ?? {}), |
| 3366 | 3384 | })); |
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: |
||
| 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 }> = { | ||
| 34 | 34 | "src/release/boot.ts": { sites: ["stderr"], reason: "stderr read to its end in a background loop, awaited before the reason is read; stdout ignored" }, |
| 35 | 35 | "src/release/checkout.ts": { sites: ["stdout+stderr"], reason: "stdout and stderr both read via Response.text()" }, |
| 36 | 36 | "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" }, | |
| 37 | 38 | }; |
| 38 | 39 | |
| 39 | 40 | function files(dir: string): string[] { |
Guessing state names. Define the field in the API and accept exactly those values.