Public: anyone can read this; the crew can change it.
`forge ship <pull>` lands a pull from your shell after a y/N, as the pull page's Merge button does #7382
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7369-forge-ship-pull-lands-a-pull-from-your-s → main 8 commits fork
6 files
changed +537
−5
README.md +8
| @@ -315,11 +315,19 @@ forge sync --ff # fast-forward clean repos, and only those | ||
| 315 | 315 | forge pin owner/name # ask the workbench to keep this one here |
| 316 | 316 | forge ls # what is here, and how stale |
| 317 | 317 | forge open owner/name --web # the page, in a browser |
| 318 | forge ship 12 # land pull #12 after a y/N, as Merge does | |
| 318 | 319 | forge pulls # open pulls of this clone, newest first |
| 319 | 320 | forge asks # what waits on you, most important first |
| 320 | 321 | forge asks --repo owner/name # only that repository's; --json for the API's answer |
| 321 | 322 | |
| 322 | 323 | |
| 324 | `forge ship <number>` reads the repository from `--repo owner/name` or from | |
| 325 | the folder's `origin` when it points at your workbench, shows the pull's | |
| 326 | title, check and whether it merges, and asks `Ship #12? [y/N]`. `--yes` skips | |
| 327 | the question; with no terminal and no `--yes` it refuses (exit 2). The | |
| 328 | merge's rules stay the forge's: a refusal prints the forge's words and exits | |
| 329 | 5 (3 for a refused token), and is never retried. | |
| 330 | ||
| 323 | 331 | `forge pulls` prints one line per pull: `#N`, the title cut to the terminal |
| 324 | 332 | width, the author, the check (`green`, `red`, `running` or `none`) and the |
| 325 | 333 | preview address when the repository is an app. It asks about the repository |
src/cli/api.ts +76 −1
| @@ -36,6 +36,17 @@ export interface Whoami { | ||
| 36 | 36 | readonly displayName: string; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | /** What `forge ship` shows before it asks. */ | |
| 40 | export interface PullSummary { | |
| 41 | readonly number: number; | |
| 42 | readonly title: string; | |
| 43 | readonly state: string; | |
| 44 | readonly merged: boolean; | |
| 45 | /** Absent once the pull is no longer open. */ | |
| 46 | readonly mergeable: boolean | null; | |
| 47 | readonly held: boolean; | |
| 48 | } | |
| 49 | ||
| 39 | 50 | /** Pages of 100 a listing follows before it says it is cut. */ |
| 40 | 51 | const PULL_PAGES = 20; |
| 41 | 52 | |
| @@ -105,12 +116,16 @@ export class ForgeApi { | ||
| 105 | 116 | } |
| 106 | 117 | if (!response.ok) { |
| 107 | 118 | const body = parsed as { error?: unknown; message?: unknown }; |
| 119 | // Some refusals are a bare code (`{ error: "checks_required" }`): the | |
| 120 | // code is then the forge's own word, and better than a status line. | |
| 108 | 121 | return err({ |
| 109 | 122 | code: typeof body.error === "string" ? body.error : "http_error", |
| 110 | 123 | message: |
| 111 | 124 | typeof body.message === "string" |
| 112 | 125 | ? body.message |
| 113 | : `${response.status} from ${path}`, | |
| 126 | : typeof body.error === "string" | |
| 127 | ? body.error | |
| 128 | : `${response.status} from ${path}`, | |
| 114 | 129 | status: response.status, |
| 115 | 130 | }); |
| 116 | 131 | } |
| @@ -194,6 +209,66 @@ export class ForgeApi { | ||
| 194 | 209 | ); |
| 195 | 210 | } |
| 196 | 211 | |
| 212 | /** One pull, without its diff: title, state, mergeable, hold. */ | |
| 213 | async pull( | |
| 214 | owner: string, | |
| 215 | name: string, | |
| 216 | number: number, | |
| 217 | ): Promise<ApiResult<PullSummary>> { | |
| 218 | const out = await this.call<Record<string, unknown>>( | |
| 219 | `${this.pullPath(owner, name, number)}?diff=0`, | |
| 220 | ); | |
| 221 | if (!out.ok) return out; | |
| 222 | const r = out.value; | |
| 223 | return ok({ | |
| 224 | number: typeof r["number"] === "number" ? r["number"] : number, | |
| 225 | title: str(r["title"]) ?? "", | |
| 226 | state: str(r["state"]) ?? "open", | |
| 227 | merged: r["mergedAt"] !== null && r["mergedAt"] !== undefined, | |
| 228 | mergeable: typeof r["mergeable"] === "boolean" ? r["mergeable"] : null, | |
| 229 | held: typeof r["hold"] === "object" && r["hold"] !== null, | |
| 230 | }); | |
| 231 | } | |
| 232 | ||
| 233 | /** The state of the pull's latest check at its head: "none" when unrun. */ | |
| 234 | async pullCheck( | |
| 235 | owner: string, | |
| 236 | name: string, | |
| 237 | number: number, | |
| 238 | ): Promise<ApiResult<string>> { | |
| 239 | const out = await this.call<{ state?: unknown }>( | |
| 240 | `${this.pullPath(owner, name, number)}/checks`, | |
| 241 | ); | |
| 242 | if (!out.ok) return out; | |
| 243 | return ok(str(out.value.state) ?? "none"); | |
| 244 | } | |
| 245 | ||
| 246 | /** | |
| 247 | * Land a pull. The server's rules decide; a refusal comes back as the | |
| 248 | * server's own error, never retried. | |
| 249 | */ | |
| 250 | async merge( | |
| 251 | owner: string, | |
| 252 | name: string, | |
| 253 | number: number, | |
| 254 | ): Promise<ApiResult<{ mergeOid: string }>> { | |
| 255 | const out = await this.call<{ mergeOid?: unknown }>( | |
| 256 | `${this.pullPath(owner, name, number)}/merge`, | |
| 257 | { method: "POST", body: "{}" }, | |
| 258 | ); | |
| 259 | if (!out.ok) return out; | |
| 260 | return ok({ mergeOid: str(out.value.mergeOid) ?? "" }); | |
| 261 | } | |
| 262 | ||
| 263 | private pullPath(owner: string, name: string, number: number): string { | |
| 264 | return `/api/v1/repos/${encode(owner)}/${encode(name)}/pulls/${number}`; | |
| 265 | } | |
| 266 | ||
| 267 | /** A pull's page. */ | |
| 268 | pullUrl(owner: string, name: string, number: number): string { | |
| 269 | return `${this.webUrl(owner, name)}/pulls/${number}`; | |
| 270 | } | |
| 271 | ||
| 197 | 272 | /** |
| 198 | 273 | * The pulls of one repository, newest first, in the API's own shape. The |
| 199 | 274 | * cursor is followed to the end (at most `PULL_PAGES` pages), so the answer |
src/cli/here.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * The repository of the folder you are in: `--repo owner/name` when given, | |
| 3 | * else the current folder's `origin` remote — but only when that remote | |
| 4 | * points at the configured workbench. A clone of somewhere else is not a | |
| 5 | * repository this forge can act on, and guessing would act on the wrong one. | |
| 6 | */ | |
| 7 | ||
| 8 | import { runLocal } from "../git/local.ts"; | |
| 9 | import { parseRepoRef } from "./args.ts"; | |
| 10 | ||
| 11 | export interface RepoRef { | |
| 12 | readonly owner: string; | |
| 13 | readonly name: string; | |
| 14 | } | |
| 15 | ||
| 16 | /** | |
| 17 | * `owner/name` from a remote URL, when its host is the workbench's. Reads | |
| 18 | * `https://host/o/n(.git)`, `ssh://user@host[:port]/o/n(.git)` and the scp | |
| 19 | * form `user@host:o/n(.git)`. | |
| 20 | */ | |
| 21 | export function repoFromRemote( | |
| 22 | remote: string, | |
| 23 | workbench: string, | |
| 24 | ): RepoRef | undefined { | |
| 25 | const want = hostOf(workbench); | |
| 26 | if (want === undefined) return undefined; | |
| 27 | const text = remote.trim(); | |
| 28 | let host: string; | |
| 29 | let path: string; | |
| 30 | const scp = /^[^/@\s]+@([^:/\s]+):(?!\/)(.+)$/.exec(text); | |
| 31 | if (scp !== null) { | |
| 32 | host = scp[1]!; | |
| 33 | path = scp[2]!; | |
| 34 | } else { | |
| 35 | let url: URL; | |
| 36 | try { | |
| 37 | url = new URL(text); | |
| 38 | } catch { | |
| 39 | return undefined; | |
| 40 | } | |
| 41 | if (!["http:", "https:", "ssh:"].includes(url.protocol)) return undefined; | |
| 42 | host = url.hostname; | |
| 43 | path = url.pathname.replace(/^\//, ""); | |
| 44 | } | |
| 45 | if (host.toLowerCase() !== want) return undefined; | |
| 46 | let decoded: string; | |
| 47 | try { | |
| 48 | decoded = decodeURIComponent(path.replace(/\.git$/, "").replace(/\/$/, "")); | |
| 49 | } catch { | |
| 50 | return undefined; | |
| 51 | } | |
| 52 | return parseRepoRef(decoded); | |
| 53 | } | |
| 54 | ||
| 55 | function hostOf(url: string): string | undefined { | |
| 56 | try { | |
| 57 | return new URL(url).hostname.toLowerCase(); | |
| 58 | } catch { | |
| 59 | return undefined; | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | /** The `origin` remote of `cwd`, or undefined when there is none. */ | |
| 64 | export async function originUrl(cwd: string): Promise<string | undefined> { | |
| 65 | try { | |
| 66 | const out = await runLocal(["remote", "get-url", "origin"], { cwd }); | |
| 67 | return out.ok && out.value.trim() !== "" ? out.value.trim() : undefined; | |
| 68 | } catch { | |
| 69 | return undefined; | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * `--repo` if given (and well-formed), else the folder's origin. The string | |
| 75 | * case is the one line to print when neither names a repository. | |
| 76 | */ | |
| 77 | export async function resolveRepo( | |
| 78 | given: string | undefined, | |
| 79 | workbench: string, | |
| 80 | cwd: string = process.cwd(), | |
| 81 | ): Promise<RepoRef | string> { | |
| 82 | if (given !== undefined) { | |
| 83 | return parseRepoRef(given) ?? "--repo expects owner/name"; | |
| 84 | } | |
| 85 | const remote = await originUrl(cwd); | |
| 86 | if (remote === undefined) | |
| 87 | return "no origin remote here — pass --repo owner/name"; | |
| 88 | // The remote itself is not echoed: an https remote may carry a token. | |
| 89 | return ( | |
| 90 | repoFromRemote(remote, workbench) ?? | |
| 91 | `origin is not on ${workbench} — pass --repo owner/name` | |
| 92 | ); | |
| 93 | } |
src/cli/main.ts +116 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * forge sync clone what is missing, fetch the rest, report |
| 8 | 8 | * forge status the same report without touching the network |
| 9 | 9 | * forge asks what waits on you, most important first |
| 10 | * forge ship <number> land a pull after a y/N, as its Merge button does | |
| 10 | 11 | * |
| 11 | 12 | * Everything here is a thin shell around three modules: `api.ts` talks to the |
| 12 | 13 | * workbench, `sync.ts` decides what should be on disk, `report.ts` prints it. |
| @@ -15,7 +16,7 @@ | ||
| 15 | 16 | */ |
| 16 | 17 | |
| 17 | 18 | import { parseArgs, parseDuration, parseRepoRef, type Args } from "./args.ts"; |
| 18 | import { ForgeApi, type Ask } from "./api.ts"; | |
| 19 | import { ForgeApi, type ApiError, type Ask } from "./api.ts"; | |
| 19 | 20 | import { |
| 20 | 21 | clearConfig, |
| 21 | 22 | configPath, |
| @@ -29,6 +30,7 @@ import { | ||
| 29 | 30 | } from "./config.ts"; |
| 30 | 31 | import { folderRepo } from "./folder-repo.ts"; |
| 31 | 32 | import { systemNotifier } from "./notify.ts"; |
| 33 | import { resolveRepo } from "./here.ts"; | |
| 32 | 34 | import { cell, createOut, inert, type Out } from "./out.ts"; |
| 33 | 35 | import { dirtyCount, printReport, settled, since } from "./report.ts"; |
| 34 | 36 | import { |
| @@ -54,11 +56,22 @@ export const EXIT = { | ||
| 54 | 56 | notConfigured: 3, |
| 55 | 57 | /** The run completed, but at least one repo did not. */ |
| 56 | 58 | partial: 4, |
| 59 | /** The forge heard the request and said no: a rule, a conflict, a hold. */ | |
| 60 | refused: 5, | |
| 57 | 61 | } as const; |
| 58 | 62 | |
| 59 | 63 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo"]; |
| 60 | 64 | |
| 61 | export async function main(argv: readonly string[]): Promise<number> { | |
| 65 | /** What the CLI needs from the person at the keyboard; a test fakes it. */ | |
| 66 | export interface Io { | |
| 67 | /** One answered line, or null when there is no terminal to ask. */ | |
| 68 | ask(prompt: string): Promise<string | null>; | |
| 69 | } | |
| 70 | ||
| 71 | export async function main( | |
| 72 | argv: readonly string[], | |
| 73 | io: Io = { ask: askLine }, | |
| 74 | ): Promise<number> { | |
| 62 | 75 | const args = parseArgs(argv, { values: VALUE_FLAGS }); |
| 63 | 76 | const out = createOut(); |
| 64 | 77 | |
| @@ -104,6 +117,8 @@ export async function main(argv: readonly string[]): Promise<number> { | ||
| 104 | 117 | return openRepo(out, args); |
| 105 | 118 | case "asks": |
| 106 | 119 | return asks(out, args); |
| 120 | case "ship": | |
| 121 | return ship(out, args, io); | |
| 107 | 122 | case "ui": |
| 108 | 123 | return openTui(out, args); |
| 109 | 124 | default: |
| @@ -136,6 +151,7 @@ function usage(out: Out): void { | ||
| 136 | 151 | out.line( |
| 137 | 152 | b(" forge asks") + " [--repo owner/name] [--json]", |
| 138 | 153 | ); |
| 154 | out.line(b(" forge ship <number>") + " [--repo owner/name] [--yes]"); | |
| 139 | 155 | out.line(); |
| 140 | 156 | out.line( |
| 141 | 157 | out.paint(` config ${configPath()} root ${rootDir()}`, out.c.faint), |
| @@ -578,6 +594,77 @@ function onlyRepo(raw: unknown, repo: string): unknown { | ||
| 578 | 594 | }; |
| 579 | 595 | } |
| 580 | 596 | |
| 597 | // ── landing a pull ─────────────────────────────────────────────────────── | |
| 598 | ||
| 599 | /** | |
| 600 | * `forge ship 12`: read the pull, show it, ask, merge. The merge route's | |
| 601 | * rules stay the server's — this adds none and retries nothing; a refusal | |
| 602 | * prints the forge's own words. | |
| 603 | */ | |
| 604 | async function ship(out: Out, args: Args, io: Io): Promise<number> { | |
| 605 | const raw = args.positional[0]; | |
| 606 | const number = raw === undefined ? NaN : Number(raw.replace(/^#/, "")); | |
| 607 | if (!Number.isSafeInteger(number) || number < 1) { | |
| 608 | out.err("forge ship: which pull? e.g. forge ship 12"); | |
| 609 | return EXIT.usage; | |
| 610 | } | |
| 611 | const loaded = await requireConfig(out); | |
| 612 | if (typeof loaded === "number") return loaded; | |
| 613 | const ref = await resolveRepo(args.value("repo"), loaded.url); | |
| 614 | if (typeof ref === "string") { | |
| 615 | out.err(`forge ship: ${ref}`); | |
| 616 | return EXIT.usage; | |
| 617 | } | |
| 618 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 619 | const pull = await api.pull(ref.owner, ref.name, number); | |
| 620 | if (!pull.ok) return shipError(out, pull.error); | |
| 621 | const check = await api.pullCheck(ref.owner, ref.name, number); | |
| 622 | const p = pull.value; | |
| 623 | out.line(`${ref.owner}/${ref.name}#${p.number} ${inert(p.title)}`); | |
| 624 | const facts = [ | |
| 625 | `check ${check.ok ? inert(check.value) : "unknown"}`, | |
| 626 | p.merged | |
| 627 | ? "already merged" | |
| 628 | : p.state !== "open" | |
| 629 | ? p.state | |
| 630 | : p.mergeable === false | |
| 631 | ? "not mergeable" | |
| 632 | : p.mergeable === true | |
| 633 | ? "mergeable" | |
| 634 | : "mergeable unknown", | |
| 635 | ...(p.held ? ["held"] : []), | |
| 636 | ]; | |
| 637 | out.line(out.paint(` ${facts.join(" · ")}`, out.c.faint)); | |
| 638 | ||
| 639 | if (!args.flag("yes")) { | |
| 640 | const answer = await io.ask(`Ship #${p.number}? [y/N] `); | |
| 641 | if (answer === null) { | |
| 642 | out.err("forge ship: not a terminal — pass --yes to ship without asking"); | |
| 643 | return EXIT.usage; | |
| 644 | } | |
| 645 | if (answer.trim().toLowerCase() !== "y") { | |
| 646 | out.line("not shipped"); | |
| 647 | return EXIT.ok; | |
| 648 | } | |
| 649 | } | |
| 650 | ||
| 651 | const merged = await api.merge(ref.owner, ref.name, number); | |
| 652 | if (!merged.ok) return shipError(out, merged.error); | |
| 653 | out.line( | |
| 654 | `${out.paint(G.check, out.c.success)} shipped #${p.number} as ${merged.value.mergeOid.slice(0, 7)}`, | |
| 655 | ); | |
| 656 | out.line(api.pullUrl(ref.owner, ref.name, number)); | |
| 657 | return EXIT.ok; | |
| 658 | } | |
| 659 | ||
| 660 | function shipError(out: Out, error: ApiError): number { | |
| 661 | out.err(`forge ship: ${inert(error.message)}`); | |
| 662 | if (error.status === 401) return EXIT.notConfigured; | |
| 663 | if (error.status === 403 || error.status === 409 || error.status === 422) | |
| 664 | return EXIT.refused; | |
| 665 | return EXIT.failed; | |
| 666 | } | |
| 667 | ||
| 581 | 668 | // ── the terminal UI ────────────────────────────────────────────────────── |
| 582 | 669 | |
| 583 | 670 | async function openTui(out: Out, args: Args): Promise<number> { |
| @@ -630,6 +717,33 @@ function sleep(ms: number): Promise<void> { | ||
| 630 | 717 | return new Promise((resolve) => setTimeout(resolve, ms)); |
| 631 | 718 | } |
| 632 | 719 | |
| 720 | /** One line from the terminal, echoed; null when stdin is not a terminal. */ | |
| 721 | async function askLine(prompt: string): Promise<string | null> { | |
| 722 | const stdin = process.stdin; | |
| 723 | if (stdin.isTTY !== true || process.stdout.isTTY !== true) return null; | |
| 724 | process.stdout.write(prompt); | |
| 725 | stdin.resume(); | |
| 726 | return new Promise<string>((resolve) => { | |
| 727 | // ctrl+d or a closed terminal is an answer too, and the answer is N. | |
| 728 | const finish = (answer: string): void => { | |
| 729 | stdin.off("data", onData); | |
| 730 | stdin.off("end", onEnd); | |
| 731 | stdin.off("close", onEnd); | |
| 732 | stdin.pause(); | |
| 733 | resolve(answer); | |
| 734 | }; | |
| 735 | const onData = (chunk: Buffer): void => | |
| 736 | finish(chunk.toString("utf8").split("\n")[0] ?? ""); | |
| 737 | const onEnd = (): void => { | |
| 738 | process.stdout.write("\n"); | |
| 739 | finish(""); | |
| 740 | }; | |
| 741 | stdin.on("data", onData); | |
| 742 | stdin.once("end", onEnd); | |
| 743 | stdin.once("close", onEnd); | |
| 744 | }); | |
| 745 | } | |
| 746 | ||
| 633 | 747 | /** |
| 634 | 748 | * A secret, read without echo. Raw mode is the only way to stop the terminal |
| 635 | 749 | * printing it, and it is restored on every path — including ctrl+c, which |
test/cli-ship.test.ts added +228
| @@ -0,0 +1,228 @@ | ||
| 1 | /** | |
| 2 | * `forge ship <number>` against a fake API: the request log is the proof. | |
| 3 | * No merge is requested without a yes; `--yes` merges; a refusal prints the | |
| 4 | * forge's own words and exits with the code a script can branch on. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, beforeEach, describe, 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 { EXIT, main } from "../src/cli/main.ts"; | |
| 12 | import { repoFromRemote } from "../src/cli/here.ts"; | |
| 13 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 14 | ||
| 15 | const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname; | |
| 16 | ||
| 17 | type Merge = { status: number; body: unknown }; | |
| 18 | ||
| 19 | describe("forge ship", () => { | |
| 20 | let server: ReturnType<typeof Bun.serve>; | |
| 21 | let base = ""; | |
| 22 | let work = ""; | |
| 23 | let requests: string[] = []; | |
| 24 | let merge: Merge = { status: 200, body: {} }; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | server = Bun.serve({ | |
| 28 | port: 0, | |
| 29 | hostname: "127.0.0.1", | |
| 30 | fetch(req) { | |
| 31 | const path = new URL(req.url).pathname; | |
| 32 | requests.push(`${req.method} ${path}`); | |
| 33 | if (req.headers.get("authorization") !== "Bearer t0k") | |
| 34 | return Response.json({ error: "unauthorized", message: "sign in" }, { status: 401 }); | |
| 35 | if (req.method === "GET" && path === "/api/v1/repos/alice/notes/pulls/12") | |
| 36 | return Response.json({ | |
| 37 | number: 12, | |
| 38 | title: "Fix the \x1b[31mthing", | |
| 39 | state: "open", | |
| 40 | mergedAt: null, | |
| 41 | mergeable: true, | |
| 42 | hold: null, | |
| 43 | }); | |
| 44 | if (req.method === "GET" && path === "/api/v1/repos/alice/notes/pulls/12/checks") | |
| 45 | return Response.json({ state: "passed" }); | |
| 46 | if (req.method === "POST" && path === "/api/v1/repos/alice/notes/pulls/12/merge") | |
| 47 | return Response.json(merge.body, { status: merge.status }); | |
| 48 | return Response.json({ error: "not_found", message: "no such pull" }, { status: 404 }); | |
| 49 | }, | |
| 50 | }); | |
| 51 | base = `http://127.0.0.1:${server.port}`; | |
| 52 | work = await mkdtemp(join(tmpdir(), "forge-ship-")); | |
| 53 | }); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | server.stop(true); | |
| 57 | await rm(work, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | ||
| 60 | beforeEach(() => { | |
| 61 | requests = []; | |
| 62 | merge = { | |
| 63 | status: 200, | |
| 64 | body: { merged: true, mergeOid: "abcdef0123456789", carried: false }, | |
| 65 | }; | |
| 66 | }); | |
| 67 | ||
| 68 | const merges = () => requests.filter((r) => r.startsWith("POST")); | |
| 69 | ||
| 70 | async function forge( | |
| 71 | args: string[], | |
| 72 | cwd = work, | |
| 73 | ): Promise<{ code: number; out: string; err: string }> { | |
| 74 | const proc = Bun.spawn(["bun", CLI, ...args], { | |
| 75 | cwd, | |
| 76 | env: { | |
| 77 | PATH: process.env["PATH"] ?? "", | |
| 78 | HOME: process.env["HOME"] ?? "", | |
| 79 | FORGE_URL: base, | |
| 80 | FORGE_TOKEN: "t0k", | |
| 81 | FORGE_KEYCHAIN: "0", | |
| 82 | NO_COLOR: "1", | |
| 83 | }, | |
| 84 | stdin: "ignore", | |
| 85 | stdout: "pipe", | |
| 86 | stderr: "pipe", | |
| 87 | }); | |
| 88 | const [out, err, code] = await Promise.all([ | |
| 89 | new Response(proc.stdout).text(), | |
| 90 | new Response(proc.stderr).text(), | |
| 91 | proc.exited, | |
| 92 | ]); | |
| 93 | return { code, out, err }; | |
| 94 | } | |
| 95 | ||
| 96 | /** `main` in this process, with the question answered by `answer`. */ | |
| 97 | async function asked(answer: string | null, argv: string[]): Promise<{ code: number; prompts: string[] }> { | |
| 98 | const saved = { url: process.env["FORGE_URL"], token: process.env["FORGE_TOKEN"] }; | |
| 99 | const write = process.stdout.write.bind(process.stdout); | |
| 100 | const writeErr = process.stderr.write.bind(process.stderr); | |
| 101 | process.env["FORGE_URL"] = base; | |
| 102 | process.env["FORGE_TOKEN"] = "t0k"; | |
| 103 | process.stdout.write = (() => true) as typeof process.stdout.write; | |
| 104 | process.stderr.write = (() => true) as typeof process.stderr.write; | |
| 105 | const prompts: string[] = []; | |
| 106 | try { | |
| 107 | const code = await main(argv, { | |
| 108 | ask: async (prompt) => { | |
| 109 | prompts.push(prompt); | |
| 110 | return answer; | |
| 111 | }, | |
| 112 | }); | |
| 113 | return { code, prompts }; | |
| 114 | } finally { | |
| 115 | process.stdout.write = write; | |
| 116 | process.stderr.write = writeErr; | |
| 117 | for (const [k, v] of [["FORGE_URL", saved.url], ["FORGE_TOKEN", saved.token]] as const) { | |
| 118 | if (v === undefined) delete process.env[k]; | |
| 119 | else process.env[k] = v; | |
| 120 | } | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | test("without a terminal and without --yes it refuses, and asks nothing to merge", async () => { | |
| 125 | const r = await forge(["ship", "12", "--repo", "alice/notes"]); | |
| 126 | expect(r.code).toBe(EXIT.usage); | |
| 127 | expect(r.err.trim().split("\n")).toHaveLength(1); | |
| 128 | expect(r.err).toContain("--yes"); | |
| 129 | expect(r.out).toContain("Fix the"); | |
| 130 | expect(r.out).toContain("check passed"); | |
| 131 | expect(r.out).toContain("mergeable"); | |
| 132 | expect(r.out).not.toContain("\x1b"); | |
| 133 | expect(merges()).toEqual([]); | |
| 134 | }); | |
| 135 | ||
| 136 | test("anything but y at the question leaves the pull alone", async () => { | |
| 137 | for (const answer of ["", "n", "yes please", "N"]) { | |
| 138 | requests = []; | |
| 139 | const r = await asked(answer, ["ship", "12", "--repo", "alice/notes"]); | |
| 140 | expect(r.prompts).toEqual(["Ship #12? [y/N] "]); | |
| 141 | expect(r.code).toBe(EXIT.ok); | |
| 142 | expect(merges()).toEqual([]); | |
| 143 | } | |
| 144 | }); | |
| 145 | ||
| 146 | test("y at the question merges, once", async () => { | |
| 147 | const r = await asked("y", ["ship", "#12", "--repo", "alice/notes"]); | |
| 148 | expect(r.code).toBe(EXIT.ok); | |
| 149 | expect(merges()).toEqual(["POST /api/v1/repos/alice/notes/pulls/12/merge"]); | |
| 150 | }); | |
| 151 | ||
| 152 | test("--yes merges without asking and prints the short id and the pull's address", async () => { | |
| 153 | const r = await forge(["ship", "12", "--repo", "alice/notes", "--yes"]); | |
| 154 | expect(r.code).toBe(EXIT.ok); | |
| 155 | expect(merges()).toEqual(["POST /api/v1/repos/alice/notes/pulls/12/merge"]); | |
| 156 | expect(r.out).toContain("abcdef0"); | |
| 157 | expect(r.out).not.toContain("abcdef01"); | |
| 158 | expect(r.out).toContain(`${base}/alice/notes/pulls/12`); | |
| 159 | }); | |
| 160 | ||
| 161 | test("a 403 prints the forge's words, exits refused, and is not retried", async () => { | |
| 162 | merge = { | |
| 163 | status: 403, | |
| 164 | body: { error: "forbidden", message: "agents propose; a person lands" }, | |
| 165 | }; | |
| 166 | const r = await forge(["ship", "12", "--repo", "alice/notes", "--yes"]); | |
| 167 | expect(r.code).toBe(EXIT.refused); | |
| 168 | expect(r.err).toContain("agents propose; a person lands"); | |
| 169 | expect(merges()).toHaveLength(1); | |
| 170 | }); | |
| 171 | ||
| 172 | test("a 409 with only a code prints that code, exits refused", async () => { | |
| 173 | merge = { status: 409, body: { error: "checks_required" } }; | |
| 174 | const r = await forge(["ship", "12", "--repo", "alice/notes", "--yes"]); | |
| 175 | expect(r.code).toBe(EXIT.refused); | |
| 176 | expect(r.err).toContain("checks_required"); | |
| 177 | expect(merges()).toHaveLength(1); | |
| 178 | }); | |
| 179 | ||
| 180 | test("an unknown pull fails before any merge", async () => { | |
| 181 | const r = await forge(["ship", "99", "--repo", "alice/notes", "--yes"]); | |
| 182 | expect(r.code).toBe(EXIT.failed); | |
| 183 | expect(r.err).toContain("no such pull"); | |
| 184 | expect(merges()).toEqual([]); | |
| 185 | }); | |
| 186 | ||
| 187 | test("a bad number or --repo is a usage error with no request", async () => { | |
| 188 | for (const argv of [["ship"], ["ship", "x"], ["ship", "0"], ["ship", "12", "--repo", "a//b"]]) { | |
| 189 | requests = []; | |
| 190 | expect((await forge(argv)).code).toBe(EXIT.usage); | |
| 191 | expect(requests).toEqual([]); | |
| 192 | } | |
| 193 | }); | |
| 194 | ||
| 195 | test("the repository comes from origin when it points at the workbench", async () => { | |
| 196 | const dir = await mkdtemp(join(tmpdir(), "forge-ship-clone-")); | |
| 197 | try { | |
| 198 | const git = (args: string[]) => | |
| 199 | Bun.spawn(["git", ...args], { cwd: dir, env: gitClientEnv(), stdout: "ignore", stderr: "ignore" }).exited; | |
| 200 | await git(["init", "-q"]); | |
| 201 | await git(["remote", "add", "origin", `${base}/alice/notes.git`]); | |
| 202 | const r = await forge(["ship", "12", "--yes"], dir); | |
| 203 | expect(r.code).toBe(EXIT.ok); | |
| 204 | expect(merges()).toEqual(["POST /api/v1/repos/alice/notes/pulls/12/merge"]); | |
| 205 | ||
| 206 | requests = []; | |
| 207 | await git(["remote", "set-url", "origin", "https://elsewhere.example/alice/notes.git"]); | |
| 208 | const away = await forge(["ship", "12", "--yes"], dir); | |
| 209 | expect(away.code).toBe(EXIT.usage); | |
| 210 | expect(away.err).toContain("--repo"); | |
| 211 | expect(requests).toEqual([]); | |
| 212 | } finally { | |
| 213 | await rm(dir, { recursive: true, force: true }); | |
| 214 | } | |
| 215 | }); | |
| 216 | }); | |
| 217 | ||
| 218 | test("remote URLs in every shape git writes, only on the workbench's host", () => { | |
| 219 | const wb = "https://forge.example.com"; | |
| 220 | const want = { owner: "alice", name: "notes" }; | |
| 221 | expect(repoFromRemote("https://forge.example.com/alice/notes.git", wb)).toEqual(want); | |
| 222 | expect(repoFromRemote("https://forge.example.com/alice/notes", wb)).toEqual(want); | |
| 223 | expect(repoFromRemote("ssh://[email protected]:2222/alice/notes.git", wb)).toEqual(want); | |
| 224 | expect(repoFromRemote("[email protected]:alice/notes.git", wb)).toEqual(want); | |
| 225 | expect(repoFromRemote("https://other.example.com/alice/notes.git", wb)).toBeUndefined(); | |
| 226 | expect(repoFromRemote("https://forge.example.com/alice/notes/extra", wb)).toBeUndefined(); | |
| 227 | expect(repoFromRemote("/local/path", wb)).toBeUndefined(); | |
| 228 | }); |
test/media.test.ts +16 −2
| @@ -254,10 +254,24 @@ describe("the media permits", () => { | ||
| 254 | 254 | const stream = await blobStream(dir, oid, 0, CLIP.length - 1, { idleMs: 50 }); |
| 255 | 255 | if (typeof stream === "string") throw new Error(stream); |
| 256 | 256 | const reader = stream.getReader(); |
| 257 | expect((await reader.read()).done).toBe(false); | |
| 257 | const first = await reader.read(); | |
| 258 | expect(first.done).toBe(false); | |
| 258 | 259 | // Then it never pulls again. |
| 259 | 260 | expect(await settled()).toBe(0); |
| 260 | await expect(reader.read()).rejects.toThrow("idle"); | |
| 261 | // What was queued before the stall still reads; then the stream ends. | |
| 262 | // A loaded machine can hand the whole blob over in the first two pipe | |
| 263 | // reads, and then the range ends before any stall — so either the idle | |
| 264 | // timer cut it short, or every byte had already arrived. | |
| 265 | let received = first.value?.length ?? 0; | |
| 266 | let cut: unknown = null; | |
| 267 | try { | |
| 268 | for (let next = await reader.read(); !next.done; next = await reader.read()) | |
| 269 | received += next.value.length; | |
| 270 | } catch (error) { | |
| 271 | cut = error; | |
| 272 | } | |
| 273 | if (received < CLIP.length) expect(String(cut)).toContain("idle"); | |
| 274 | else expect(cut).toBeNull(); | |
| 261 | 275 | }); |
| 262 | 276 | |
| 263 | 277 | test("a stream nobody ever reads ends too", async () => { |
new above
offline