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

Merged opened this

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

Merged

mason/forge:mason/7369-forge-ship-pull-lands-a-pull-from-your-s → main 8 commits fork

  • Merged. mason/7369-forge-ship-pull-lands-a-pull-from-your-s was merged into main as cc5e3be.

Review

Sign in to review this change.
6 files changed +537 −5
README.md +8
@@ -315,11 +315,19 @@ forge sync --ff # fast-forward clean repos, and only those
315315 forge pin owner/name # ask the workbench to keep this one here
316316 forge ls # what is here, and how stale
317317 forge open owner/name --web # the page, in a browser
318 forge ship 12 # land pull #12 after a y/N, as Merge does
318319 forge pulls # open pulls of this clone, newest first
319320 forge asks # what waits on you, most important first
320321 forge asks --repo owner/name # only that repository's; --json for the API's answer
321322 ```
322323
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
323331 `forge pulls` prints one line per pull: `#N`, the title cut to the terminal
324332 width, the author, the check (`green`, `red`, `running` or `none`) and the
325333 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 {
3636 readonly displayName: string;
3737 }
3838
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
3950 /** Pages of 100 a listing follows before it says it is cut. */
4051 const PULL_PAGES = 20;
4152
@@ -105,12 +116,16 @@ export class ForgeApi {
105116 }
106117 if (!response.ok) {
107118 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.
108121 return err({
109122 code: typeof body.error === "string" ? body.error : "http_error",
110123 message:
111124 typeof body.message === "string"
112125 ? body.message
113 : `${response.status} from ${path}`,
126 : typeof body.error === "string"
127 ? body.error
128 : `${response.status} from ${path}`,
114129 status: response.status,
115130 });
116131 }
@@ -194,6 +209,66 @@ export class ForgeApi {
194209 );
195210 }
196211
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
197272 /**
198273 * The pulls of one repository, newest first, in the API's own shape. The
199274 * 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 @@
77 * forge sync clone what is missing, fetch the rest, report
88 * forge status the same report without touching the network
99 * 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
1011 *
1112 * Everything here is a thin shell around three modules: `api.ts` talks to the
1213 * workbench, `sync.ts` decides what should be on disk, `report.ts` prints it.
@@ -15,7 +16,7 @@
1516 */
1617
1718 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";
1920 import {
2021 clearConfig,
2122 configPath,
@@ -29,6 +30,7 @@ import {
2930 } from "./config.ts";
3031 import { folderRepo } from "./folder-repo.ts";
3132 import { systemNotifier } from "./notify.ts";
33 import { resolveRepo } from "./here.ts";
3234 import { cell, createOut, inert, type Out } from "./out.ts";
3335 import { dirtyCount, printReport, settled, since } from "./report.ts";
3436 import {
@@ -54,11 +56,22 @@ export const EXIT = {
5456 notConfigured: 3,
5557 /** The run completed, but at least one repo did not. */
5658 partial: 4,
59 /** The forge heard the request and said no: a rule, a conflict, a hold. */
60 refused: 5,
5761 } as const;
5862
5963 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo"];
6064
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> {
6275 const args = parseArgs(argv, { values: VALUE_FLAGS });
6376 const out = createOut();
6477
@@ -104,6 +117,8 @@ export async function main(argv: readonly string[]): Promise<number> {
104117 return openRepo(out, args);
105118 case "asks":
106119 return asks(out, args);
120 case "ship":
121 return ship(out, args, io);
107122 case "ui":
108123 return openTui(out, args);
109124 default:
@@ -136,6 +151,7 @@ function usage(out: Out): void {
136151 out.line(
137152 b(" forge asks") + " [--repo owner/name] [--json]",
138153 );
154 out.line(b(" forge ship <number>") + " [--repo owner/name] [--yes]");
139155 out.line();
140156 out.line(
141157 out.paint(` config ${configPath()} root ${rootDir()}`, out.c.faint),
@@ -578,6 +594,77 @@ function onlyRepo(raw: unknown, repo: string): unknown {
578594 };
579595 }
580596
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
581668 // ── the terminal UI ──────────────────────────────────────────────────────
582669
583670 async function openTui(out: Out, args: Args): Promise<number> {
@@ -630,6 +717,33 @@ function sleep(ms: number): Promise<void> {
630717 return new Promise((resolve) => setTimeout(resolve, ms));
631718 }
632719
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
633747 /**
634748 * A secret, read without echo. Raw mode is the only way to stop the terminal
635749 * 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", () => {
254254 const stream = await blobStream(dir, oid, 0, CLIP.length - 1, { idleMs: 50 });
255255 if (typeof stream === "string") throw new Error(stream);
256256 const reader = stream.getReader();
257 expect((await reader.read()).done).toBe(false);
257 const first = await reader.read();
258 expect(first.done).toBe(false);
258259 // Then it never pulls again.
259260 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();
261275 });
262276
263277 test("a stream nobody ever reads ends too", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs