@cldmv/gh-broker 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. package/tsconfig.json +0 -21
@@ -1,196 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { createNpmToken, wantsOtp } from "../deploy/lib/npm-setup.mjs";
3
-
4
- const SESSION = "npm_SESSIONTOKEN";
5
- const NEW_TOKEN = "npm_NEWGRANULARTOKEN";
6
- const PASSWORD = "hunter2-secret";
7
-
8
- type Reply = { status: number; body?: unknown; headers?: Record<string, string> };
9
- const reply = (r: Reply) => ({
10
- status: r.status,
11
- headers: { get: (n: string) => r.headers?.[n.toLowerCase()] ?? null },
12
- text: async () => (typeof r.body === "string" ? r.body : JSON.stringify(r.body ?? {}))
13
- });
14
-
15
- /** A scripted registry: each route pops its next reply. */
16
- function fakeNpm(script: { login?: Reply; polls?: Reply[]; tokens?: Reply[]; logout?: Reply | Error }) {
17
- const calls: Array<{ method: string; url: string; headers: Record<string, string>; body?: any }> = [];
18
- const polls = [...(script.polls ?? [{ status: 200, body: { token: SESSION } }])];
19
- const tokens = [...(script.tokens ?? [{ status: 201, body: { token: NEW_TOKEN, expires: "2027-01-29T00:00:00.000Z", key: "k" } }])];
20
- const fetch = vi.fn(async (url: string, init: any) => {
21
- calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: JSON.parse(init.body) } : {}) });
22
- const path = new URL(url).pathname;
23
- if (path === "/-/v1/login") return reply(script.login ?? { status: 200, body: { loginUrl: "https://www.npmjs.com/login?next=/login/cli/abc", doneUrl: "https://registry.npmjs.org/-/v1/done?authId=abc" } });
24
- if (path === "/-/v1/done") return reply(polls.shift() ?? { status: 500 });
25
- if (path === "/-/npm/v1/tokens") return reply(tokens.shift() ?? { status: 500 });
26
- if (path.startsWith("/-/user/token/")) {
27
- if (script.logout instanceof Error) throw script.logout;
28
- return reply(script.logout ?? { status: 200, body: { ok: true } });
29
- }
30
- throw new Error(`unexpected ${init.method} ${url}`);
31
- });
32
- return { fetch, calls };
33
- }
34
-
35
- /** Every answer, the OTP included, is read through the hidden prompt, in call order. */
36
- function prompts(answers: string[]) {
37
- return { promptHidden: vi.fn(async () => answers.shift() ?? "") };
38
- }
39
-
40
- const run = (npm: ReturnType<typeof fakeNpm>, p = prompts([PASSWORD, "111111"]), extra: Record<string, unknown> = {}) => {
41
- const logs: string[] = [];
42
- const done = createNpmToken({ scopes: ["@cldmv"], tokenName: "gh-broker test", fetch: npm.fetch as any, log: (m: string) => logs.push(m), ...p, ...extra });
43
- return { done, logs, p };
44
- };
45
-
46
- describe("createNpmToken", () => {
47
- it("logs in through the web flow, creates a scoped read-write token without 2FA bypass, and revokes the session", async () => {
48
- const npm = fakeNpm({});
49
- const { done, logs } = run(npm);
50
- await expect(done).resolves.toEqual({ token: NEW_TOKEN, expires: "2027-01-29T00:00:00.000Z" });
51
- expect(logs.join("\n")).toContain("https://www.npmjs.com/login?next=/login/cli/abc");
52
-
53
- const create = npm.calls.find((c) => c.url.endsWith("/-/npm/v1/tokens"))!;
54
- expect(create.headers.authorization).toBe(`Bearer ${SESSION}`);
55
- expect(create.headers["npm-otp"]).toBe("111111");
56
- expect(create.body).toEqual({ name: "gh-broker test", scopes: ["@cldmv"], packages_and_scopes_permission: "read-write", expires: 90, password: PASSWORD });
57
- expect(create.body).not.toHaveProperty("bypass_2fa");
58
-
59
- const logout = npm.calls.at(-1)!;
60
- expect(logout.method).toBe("DELETE");
61
- expect(logout.url).toBe(`https://registry.npmjs.org/-/user/token/${SESSION}`);
62
- expect(logs).toContain(" npm login session revoked");
63
- });
64
-
65
- it("waits while the login is pending, honouring retry-after", async () => {
66
- vi.useFakeTimers();
67
- try {
68
- const npm = fakeNpm({ polls: [{ status: 202, headers: { "retry-after": "2" } }, { status: 202 }, { status: 200, body: { token: SESSION } }] });
69
- const { done } = run(npm);
70
- await vi.advanceTimersByTimeAsync(3000);
71
- await expect(done).resolves.toMatchObject({ token: NEW_TOKEN });
72
- expect(npm.calls.filter((c) => c.url.includes("/-/v1/done"))).toHaveLength(3);
73
- } finally {
74
- vi.useRealTimers();
75
- }
76
- });
77
-
78
- it("gives up when the login is never approved", async () => {
79
- const npm = fakeNpm({ polls: Array.from({ length: 5 }, () => ({ status: 202, headers: { "retry-after": "0.001" } })) });
80
- await expect(run(npm, undefined, { pollTimeoutMs: -1 }).done).rejects.toThrow(/timed out waiting for approval/);
81
- });
82
-
83
- it("re-prompts for the OTP when npm rejects it", async () => {
84
- const npm = fakeNpm({ tokens: [{ status: 401, headers: { "www-authenticate": "OTP" } }, { status: 201, body: { token: NEW_TOKEN } }] });
85
- const p = prompts([PASSWORD, "111111", "222222"]);
86
- await expect(run(npm, p).done).resolves.toEqual({ token: NEW_TOKEN });
87
- const creates = npm.calls.filter((c) => c.url.endsWith("/-/npm/v1/tokens"));
88
- expect(creates.map((c) => c.headers["npm-otp"])).toEqual(["111111", "222222"]);
89
- // password + first OTP + the replacement OTP: the password is not asked for again
90
- expect(p.promptHidden).toHaveBeenCalledTimes(3);
91
- });
92
-
93
- it("re-prompts for the password (and a new OTP) when npm rejects it", async () => {
94
- const npm = fakeNpm({ tokens: [{ status: 401, body: { error: "invalid password" } }, { status: 201, body: { token: NEW_TOKEN } }] });
95
- const p = prompts([PASSWORD, "111111", "right-password", "222222"]);
96
- await expect(run(npm, p).done).resolves.toEqual({ token: NEW_TOKEN });
97
- expect(npm.calls.filter((c) => c.url.endsWith("/-/npm/v1/tokens")).map((c) => c.body.password)).toEqual([PASSWORD, "right-password"]);
98
- });
99
-
100
- it("stops after three attempts and still revokes the session", async () => {
101
- const otpNo = { status: 401, headers: { "www-authenticate": "OTP" } };
102
- const npm = fakeNpm({ tokens: [otpNo, otpNo, { status: 401, body: { error: "You must provide a one-time pass." } }] });
103
- const { done, logs } = run(npm, prompts([PASSWORD, "1", "2", "3"]));
104
- await expect(done).rejects.toThrow(/npm token creation failed \(HTTP 401\)/);
105
- expect(npm.calls.at(-1)!.method).toBe("DELETE");
106
- expect(logs).toContain(" npm login session revoked");
107
- });
108
-
109
- it("fails on an unexpected registry error, revoking the session", async () => {
110
- const npm = fakeNpm({ tokens: [{ status: 400, body: { message: "expires must be at most 90 days" } }] });
111
- await expect(run(npm).done).rejects.toThrow(/HTTP 400\): expires must be at most 90 days/);
112
- expect(npm.calls.at(-1)!.method).toBe("DELETE");
113
- });
114
-
115
- it("fails when npm returns no token", async () => {
116
- await expect(run(fakeNpm({ tokens: [{ status: 201, body: {} }] })).done).rejects.toThrow(/did not return it/);
117
- });
118
-
119
- it("reports an unrevokable session as a note, not a failure", async () => {
120
- const a = run(fakeNpm({ logout: { status: 500 } }));
121
- await expect(a.done).resolves.toMatchObject({ token: NEW_TOKEN });
122
- expect(a.logs.at(-1)).toMatch(/could not revoke the npm login session \(HTTP 500\)/);
123
- const b = run(fakeNpm({ logout: new Error("socket hang up") }));
124
- await expect(b.done).resolves.toMatchObject({ token: NEW_TOKEN });
125
- expect(b.logs.at(-1)).toMatch(/could not revoke the npm login session \(socket hang up\)/);
126
- });
127
-
128
- it.each([
129
- [{ status: 404, body: "Not Found" }, /could not start \(HTTP 404\): Not Found/],
130
- [{ status: 200, body: { loginUrl: "http://insecure", doneUrl: "https://x" } }, /could not start/]
131
- ] as Array<[Reply, RegExp]>)("fails when the web login cannot start (%#)", async (login, msg) => {
132
- const npm = fakeNpm({ login });
133
- await expect(run(npm).done).rejects.toThrow(msg);
134
- expect(npm.calls).toHaveLength(1);
135
- });
136
-
137
- it.each([
138
- [{ status: 200, body: {} }, /finished without a session/],
139
- [{ status: 403, body: { error: "denied" } }, /web login failed \(HTTP 403\): denied/]
140
- ] as Array<[Reply, RegExp]>)("fails when the login poll goes wrong (%#)", async (poll, msg) => {
141
- await expect(run(fakeNpm({ polls: [poll] })).done).rejects.toThrow(msg);
142
- });
143
-
144
- it("never logs the password, the OTP or any token", async () => {
145
- const { done, logs } = run(fakeNpm({}));
146
- await done;
147
- const all = logs.join("\n");
148
- for (const secret of [PASSWORD, "111111", SESSION, NEW_TOKEN]) expect(all).not.toContain(secret);
149
- });
150
-
151
- it("adds org read-write only when orgs are given", async () => {
152
- const npm = fakeNpm({});
153
- await run(npm, undefined, { orgs: ["cldmv"] }).done;
154
- expect(npm.calls.find((c) => c.url.endsWith("/tokens"))!.body).toMatchObject({ scopes: ["@cldmv"], orgs: ["cldmv"], orgs_permission: "read-write" });
155
- });
156
-
157
- it("rejects a login URL that does not parse", async () => {
158
- const npm = fakeNpm({ login: { status: 200, body: { loginUrl: "not a url", doneUrl: "https://registry.npmjs.org/-/v1/done" } } });
159
- await expect(run(npm).done).rejects.toThrow(/could not start/);
160
- });
161
-
162
- it("sends no npm-otp header when the code is left empty", async () => {
163
- const npm = fakeNpm({});
164
- await run(npm, prompts([PASSWORD, ""])).done;
165
- expect(npm.calls.find((c) => c.url.endsWith("/tokens"))!.headers).not.toHaveProperty("npm-otp");
166
- });
167
-
168
- it("uses the global fetch when none is passed", async () => {
169
- const npm = fakeNpm({});
170
- vi.stubGlobal("fetch", npm.fetch);
171
- try {
172
- const logs: string[] = [];
173
- await expect(createNpmToken({ scopes: ["@cldmv"], tokenName: "t", log: (m: string) => logs.push(m), ...prompts([PASSWORD, "111111"]) })).resolves.toMatchObject({ token: NEW_TOKEN });
174
- } finally {
175
- vi.unstubAllGlobals();
176
- }
177
- });
178
-
179
- it("uses a custom registry and token lifetime", async () => {
180
- const npm = fakeNpm({ login: { status: 200, body: { loginUrl: "https://npm.example.test/login", doneUrl: "https://npm.example.test/-/v1/done?x" } } });
181
- await run(npm, undefined, { registry: "https://npm.example.test/", days: 30 }).done;
182
- expect(npm.calls[0]!.url).toBe("https://npm.example.test/-/v1/login");
183
- expect(npm.calls.find((c) => c.url.endsWith("/tokens"))!.body.expires).toBe(30);
184
- });
185
- });
186
-
187
- describe("wantsOtp", () => {
188
- const res = (status: number, h: Record<string, string> = {}) => ({ status, headers: { get: (n: string) => h[n] ?? null } });
189
- it("recognises npm's OTP signals and nothing else", () => {
190
- expect(wantsOtp(res(401, { "www-authenticate": "OTP" }), {})).toBe(true);
191
- expect(wantsOtp(res(401), { error: "You must provide a one-time pass." })).toBe(true);
192
- expect(wantsOtp(res(401), { error: "bad password" })).toBe(false);
193
- expect(wantsOtp(res(401), undefined)).toBe(false);
194
- expect(wantsOtp(res(403, { "www-authenticate": "OTP" }), {})).toBe(false);
195
- });
196
- });
@@ -1,57 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from "vitest";
2
- import { configDir, resolveConfigPath, resolveSecretsPath } from "../src/paths.ts";
3
-
4
- describe("paths", () => {
5
- beforeEach(() => {
6
- // The imported `env` binding is the live process.env object, so mutating keys
7
- // (not reassigning) is reflected. Clear the ones under test.
8
- delete process.env.GH_BROKER_CONFIG;
9
- delete process.env.GH_BROKER_SECRETS;
10
- delete process.env.GH_BROKER_CONFIG_DIR;
11
- });
12
-
13
- it("configDir defaults to /etc/gh-broker on linux (the test host)", () => {
14
- expect(configDir()).toBe("/etc/gh-broker");
15
- });
16
-
17
- it("configDir honors the GH_BROKER_CONFIG_DIR override", () => {
18
- process.env.GH_BROKER_CONFIG_DIR = "/opt/cfg";
19
- expect(configDir()).toBe("/opt/cfg");
20
- });
21
-
22
- it("resolveConfigPath precedence: explicit > env > default", () => {
23
- expect(resolveConfigPath("/explicit.json")).toBe("/explicit.json");
24
- process.env.GH_BROKER_CONFIG = "/env.json";
25
- expect(resolveConfigPath()).toBe("/env.json");
26
- delete process.env.GH_BROKER_CONFIG;
27
- expect(resolveConfigPath()).toBe("/etc/gh-broker/config.json");
28
- });
29
-
30
- it("resolveSecretsPath precedence: explicit > env > default", () => {
31
- expect(resolveSecretsPath("/explicit-s.json")).toBe("/explicit-s.json");
32
- process.env.GH_BROKER_SECRETS = "/env-s.json";
33
- expect(resolveSecretsPath()).toBe("/env-s.json");
34
- delete process.env.GH_BROKER_SECRETS;
35
- expect(resolveSecretsPath()).toBe("/etc/gh-broker/secrets.json");
36
- });
37
-
38
- it("configDir maps darwin and win32 platforms", async () => {
39
- vi.resetModules();
40
- vi.doMock("node:process", () => ({ platform: "darwin", env: {} }));
41
- const mac = await import("../src/paths.ts");
42
- expect(mac.configDir()).toBe("/Library/Application Support/gh-broker");
43
-
44
- vi.resetModules();
45
- vi.doMock("node:process", () => ({ platform: "win32", env: { ProgramData: "C:/PD" } }));
46
- const win = await import("../src/paths.ts");
47
- expect(win.configDir()).toContain("gh-broker");
48
-
49
- vi.resetModules();
50
- vi.doMock("node:process", () => ({ platform: "win32", env: {} }));
51
- const winDefault = await import("../src/paths.ts");
52
- expect(winDefault.configDir()).toContain("C:\\ProgramData");
53
-
54
- vi.doUnmock("node:process");
55
- vi.resetModules();
56
- });
57
- });
@@ -1,128 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- // Stand-ins for process.stdin / process.stdout so the raw-mode reader can be driven by hand.
4
- const h = vi.hoisted(() => ({ stdin: null as any, stdout: null as any }));
5
- vi.mock("node:process", async () => {
6
- const { EventEmitter } = await import("node:events");
7
- class FakeStdin extends EventEmitter {
8
- isTTY = true;
9
- raw: boolean[] = [];
10
- paused = false;
11
- setRawMode(v: boolean) {
12
- this.raw.push(v);
13
- }
14
- resume() {
15
- this.paused = false;
16
- }
17
- pause() {
18
- this.paused = true;
19
- }
20
- }
21
- h.stdin = new FakeStdin();
22
- h.stdout = { out: "", write(s: string) { this.out += s; return true; } };
23
- return { stdin: h.stdin, stdout: h.stdout };
24
- });
25
-
26
- import { promptLine, promptHidden, confirm, choose } from "../deploy/lib/prompt.mjs";
27
-
28
- const type = (s: string) => h.stdin.emit("data", Buffer.from(s, "utf8"));
29
-
30
- beforeEach(() => {
31
- h.stdout.out = "";
32
- h.stdin.removeAllListeners("data");
33
- h.stdin.raw = [];
34
- h.stdin.isTTY = true;
35
- });
36
-
37
- describe("promptLine / promptHidden", () => {
38
- it("echoes typed characters and returns the trimmed line on Enter", async () => {
39
- const p = promptLine("name? ");
40
- type(" ab");
41
- type("c \r");
42
- expect(await p).toBe("abc");
43
- expect(h.stdout.out).toBe("name? abc \n");
44
- expect(h.stdin.raw).toEqual([true, false]);
45
- expect(h.stdin.paused).toBe(true);
46
- expect(h.stdin.listenerCount("data")).toBe(0);
47
- });
48
-
49
- it("does not echo hidden input", async () => {
50
- const p = promptHidden("secret: ");
51
- type("hunter2\n");
52
- expect(await p).toBe("hunter2");
53
- expect(h.stdout.out).toBe("secret: \n");
54
- });
55
-
56
- it("handles Backspace (DEL and BS), including on empty input", async () => {
57
- const p = promptLine("? ");
58
- type("\x7f"); // nothing to delete
59
- type("abc\x7f\x08d\n");
60
- expect(await p).toBe("ad");
61
- expect(h.stdout.out).toContain("\b \b");
62
-
63
- const q = promptHidden("? ");
64
- h.stdout.out = "";
65
- type("ab\x7f\n");
66
- expect(await q).toBe("a");
67
- expect(h.stdout.out).not.toContain("\b \b");
68
- });
69
-
70
- it("returns what was typed on Ctrl-D, and rejects on Ctrl-C", async () => {
71
- const p = promptLine("? ");
72
- type("xy\x04");
73
- expect(await p).toBe("xy");
74
-
75
- const q = promptLine("? ");
76
- type("xy\x03");
77
- await expect(q).rejects.toThrow("aborted");
78
- });
79
-
80
- it("skips raw mode when stdin is not a TTY", async () => {
81
- h.stdin.isTTY = false;
82
- const p = promptLine("? ");
83
- type("ok\n");
84
- expect(await p).toBe("ok");
85
- expect(h.stdin.raw).toEqual([]);
86
- });
87
- });
88
-
89
- describe("confirm", () => {
90
- it.each([
91
- ["y\n", true],
92
- ["YES\n", true],
93
- ["n\n", false],
94
- ["\n", false],
95
- ["yeah\n", false]
96
- ])("%j -> %s", async (input, expected) => {
97
- const p = confirm("sure?");
98
- type(input);
99
- expect(await p).toBe(expected);
100
- expect(h.stdout.out).toContain("sure? [y/N] ");
101
- });
102
- });
103
-
104
- describe("choose", () => {
105
- const opts = [
106
- { key: "a", label: "Alpha" },
107
- { key: "b", label: "Beta" }
108
- ];
109
-
110
- it("lists the options and returns the chosen key", async () => {
111
- const p = choose("pick", opts);
112
- type("2\n");
113
- expect(await p).toBe("b");
114
- expect(h.stdout.out).toContain("pick\n 1) Alpha\n 2) Beta\n");
115
- });
116
-
117
- it("uses the default on empty input, and falls back to it on invalid input", async () => {
118
- let p = choose("pick", opts, 2);
119
- type("\n");
120
- expect(await p).toBe("b");
121
- p = choose("pick", opts);
122
- type("9\n");
123
- expect(await p).toBe("a");
124
- p = choose("pick", opts);
125
- type("zz\n");
126
- expect(await p).toBe("a");
127
- });
128
- });
@@ -1,247 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { reportReleasePrs, parseReleaseVersion, changelogPaths, REPORT_RELEASE_PRS_SCHEMA } from "../src/verbs/report_release_prs.ts";
3
-
4
- type Handler = (route: string, params: any) => Promise<{ data: unknown; headers?: Record<string, string> }>;
5
-
6
- function octokitWith(handler: Handler): any {
7
- return { request: vi.fn(handler) };
8
- }
9
-
10
- const status = (code: number, message = "x") => Object.assign(new Error(message), { status: code });
11
-
12
- const hit = (repo: string, number: number, title: string, over: Record<string, unknown> = {}) => ({
13
- number,
14
- title,
15
- html_url: `https://github.com/CLDMV/${repo}/pull/${number}`,
16
- repository_url: `https://api.github.com/repos/CLDMV/${repo}`,
17
- ...over
18
- });
19
-
20
- const search = (items: unknown[], over: Record<string, unknown> = {}) => ({ data: { total_count: items.length, incomplete_results: false, items, ...over }, headers: {} });
21
-
22
- const pull = (head = "next", base = "master", draft = false) => ({ data: { head: { ref: head }, base: { ref: base }, draft }, headers: {} });
23
-
24
- describe("parseReleaseVersion", () => {
25
- it("reads the version out of a release title, with or without the v", () => {
26
- expect(parseReleaseVersion("release: v0.4.2 - bump the patch group")).toBe("0.4.2");
27
- expect(parseReleaseVersion("release: 1.0.0")).toBe("1.0.0");
28
- expect(parseReleaseVersion("Release: v2.10.13-rc.1 - x")).toBe("2.10.13-rc.1");
29
- });
30
-
31
- it("returns null when the title carries no version", () => {
32
- expect(parseReleaseVersion("chore: tidy up")).toBeNull();
33
- expect(parseReleaseVersion("release: soon")).toBeNull();
34
- });
35
- });
36
-
37
- describe("changelogPaths", () => {
38
- it("lists both changelog/ and changelogs/ under the major folder", () => {
39
- expect(changelogPaths("0.4.2")).toEqual(["docs/changelog/v0/v0.4.2.md", "docs/changelogs/v0/v0.4.2.md"]);
40
- expect(changelogPaths("12.3.4")).toEqual(["docs/changelog/v12/v12.3.4.md", "docs/changelogs/v12/v12.3.4.md"]);
41
- });
42
- });
43
-
44
- describe("reportReleasePrs", () => {
45
- it("searches the org once for open release-labelled PRs", async () => {
46
- const octokit = octokitWith(async () => search([]));
47
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
48
- expect(octokit.request).toHaveBeenCalledTimes(1);
49
- expect(octokit.request).toHaveBeenCalledWith("GET /search/issues", { q: "org:cldmv is:pr is:open label:release", per_page: 100 });
50
- expect(out).toEqual({ scanned: { releasePrs: 0 }, summary: { ready: 0, needsChangelog: 0, unknownVersion: 0 }, releasePrs: [] });
51
- });
52
-
53
- it("scopes the search to one repo when repo is given", async () => {
54
- const octokit = octokitWith(async () => search([]));
55
- await reportReleasePrs(octokit, { owner: "cldmv", repo: "gh-broker" });
56
- expect(octokit.request.mock.calls[0]![1].q).toBe("repo:cldmv/gh-broker is:pr is:open label:release");
57
- });
58
-
59
- it("marks a PR ready when the changelog exists at the PR head commit, and stops at the first path found", async () => {
60
- const octokit = octokitWith(async (route, params) => {
61
- if (route === "GET /search/issues") return search([hit("gh-broker", 88, "release: v0.4.2 - stuff")]);
62
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull("next", "master");
63
- if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
64
- expect(params).toMatchObject({ owner: "CLDMV", repo: "gh-broker", ref: "next", path: "docs/changelog/v0/v0.4.2.md" });
65
- return { data: { type: "file" }, headers: {} };
66
- }
67
- throw new Error(`unexpected route ${route}`);
68
- });
69
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
70
- expect(out.releasePrs).toEqual([
71
- {
72
- repo: "CLDMV/gh-broker",
73
- pull: 88,
74
- url: "https://github.com/CLDMV/gh-broker/pull/88",
75
- title: "release: v0.4.2 - stuff",
76
- version: "0.4.2",
77
- head: "next",
78
- base: "master",
79
- draft: false,
80
- status: "ready",
81
- changelogPath: "docs/changelog/v0/v0.4.2.md"
82
- }
83
- ]);
84
- expect(out.summary).toEqual({ ready: 1, needsChangelog: 0, unknownVersion: 0 });
85
- expect(octokit.request).toHaveBeenCalledTimes(3);
86
- });
87
-
88
- it("falls through to docs/changelogs/ when docs/changelog/ 404s", async () => {
89
- const octokit = octokitWith(async (route, params) => {
90
- if (route === "GET /search/issues") return search([hit("r", 1, "release: v1.2.3")]);
91
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
92
- if (params.path === "docs/changelog/v1/v1.2.3.md") throw status(404, "Not Found");
93
- return { data: {}, headers: {} };
94
- });
95
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
96
- expect(out.releasePrs[0]).toMatchObject({ status: "ready", changelogPath: "docs/changelogs/v1/v1.2.3.md" });
97
- });
98
-
99
- it("marks a PR needs-changelog and lists the paths it checked when neither exists", async () => {
100
- const octokit = octokitWith(async (route) => {
101
- if (route === "GET /search/issues") return search([hit("r", 4, "release: v0.1.0 - x")]);
102
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
103
- throw status(404, "Not Found");
104
- });
105
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
106
- expect(out.releasePrs[0]).toMatchObject({ status: "needs-changelog", version: "0.1.0", checkedPaths: ["docs/changelog/v0/v0.1.0.md", "docs/changelogs/v0/v0.1.0.md"] });
107
- expect(out.releasePrs[0]).not.toHaveProperty("changelogPath");
108
- expect(out.summary).toEqual({ ready: 0, needsChangelog: 1, unknownVersion: 0 });
109
- });
110
-
111
- it("marks a PR unknown-version, without looking for a changelog, when the title has no version", async () => {
112
- const octokit = octokitWith(async (route) => {
113
- if (route === "GET /search/issues") return search([hit("r", 6, "Release candidate")]);
114
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull("hotfixes", "master");
115
- throw new Error(`unexpected route ${route}`);
116
- });
117
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
118
- expect(out.releasePrs[0]).toMatchObject({ status: "unknown-version", version: null, head: "hotfixes" });
119
- expect(octokit.request).toHaveBeenCalledTimes(2);
120
- });
121
-
122
- it("covers hotfixes release PRs across several repos and counts each status", async () => {
123
- const octokit = octokitWith(async (route, params) => {
124
- if (route === "GET /search/issues") {
125
- return search([hit("a", 1, "release: v1.0.1 - x"), hit("b", 2, "release: v2.0.0 - y"), hit("c", 3, "no version here")]);
126
- }
127
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull(params.repo === "a" ? "hotfixes" : "next");
128
- if (params.repo === "a") return { data: {}, headers: {} };
129
- throw status(404);
130
- });
131
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
132
- expect(out.summary).toEqual({ ready: 1, needsChangelog: 1, unknownVersion: 1 });
133
- expect(out.releasePrs.map((p) => `${p.repo}:${p.head}:${p.status}`)).toEqual(["CLDMV/a:hotfixes:ready", "CLDMV/b:next:needs-changelog", "CLDMV/c:next:unknown-version"]);
134
- });
135
-
136
- it("onlyMissing drops ready PRs from the list but keeps the summary counts", async () => {
137
- const octokit = octokitWith(async (route, params) => {
138
- if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.1"), hit("b", 2, "release: v2.0.0")]);
139
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
140
- if (params.repo === "a") return { data: {}, headers: {} };
141
- throw status(404);
142
- });
143
- const out = await reportReleasePrs(octokit, { owner: "cldmv", onlyMissing: true });
144
- expect(out.releasePrs.map((p) => p.repo)).toEqual(["CLDMV/b"]);
145
- expect(out.summary).toEqual({ ready: 1, needsChangelog: 1, unknownVersion: 0 });
146
- });
147
-
148
- it("reports draft from the pull, falling back to the search hit", async () => {
149
- const octokit = octokitWith(async (route) => {
150
- if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0", { draft: true })]);
151
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return { data: { head: { ref: "next" }, base: { ref: "master" } }, headers: {} };
152
- return { data: {}, headers: {} };
153
- });
154
- expect((await reportReleasePrs(octokit, { owner: "cldmv" })).releasePrs[0]!.draft).toBe(true);
155
- const plain = octokitWith(async (route) => {
156
- if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")]);
157
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return { data: { head: { ref: "next" }, base: { ref: "master" } }, headers: {} };
158
- return { data: {}, headers: {} };
159
- });
160
- expect((await reportReleasePrs(plain, { owner: "cldmv" })).releasePrs[0]!.draft).toBe(false);
161
- });
162
-
163
- it("flags max-results when the search has more hits than one page returned", async () => {
164
- const octokit = octokitWith(async (route) => {
165
- if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")], { total_count: 150 });
166
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
167
- return { data: {}, headers: {} };
168
- });
169
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
170
- expect(out.truncated).toBe(true);
171
- expect(out.truncatedReason).toBe("max-results");
172
- expect(out.releasePrs).toHaveLength(1);
173
- });
174
-
175
- it("flags incomplete-search when GitHub says the search timed out", async () => {
176
- const octokit = octokitWith(async () => search([], { incomplete_results: true }));
177
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
178
- expect(out.truncatedReason).toBe("incomplete-search");
179
- });
180
-
181
- it("returns what it has, flagged max-api-calls, when the call budget runs out", async () => {
182
- const octokit = octokitWith(async (route) => {
183
- if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0"), hit("b", 2, "release: v2.0.0")]);
184
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
185
- return { data: {}, headers: {} };
186
- });
187
- // search (1) + pull (2) + contents (3) for the first PR, then the second PR's pull lookup is over budget.
188
- const out = await reportReleasePrs(octokit, { owner: "cldmv", maxApiCalls: 3 });
189
- expect(out.truncated).toBe(true);
190
- expect(out.truncatedReason).toBe("max-api-calls");
191
- expect(out.releasePrs).toHaveLength(1);
192
- expect(out.scanned).toEqual({ releasePrs: 2 });
193
- });
194
-
195
- it("returns partial results with retryAfterSeconds when rate limited", async () => {
196
- const octokit = octokitWith(async (route) => {
197
- if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")]);
198
- throw Object.assign(new Error("Forbidden"), { status: 403, response: { headers: { "retry-after": "20" } } });
199
- });
200
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
201
- expect(out.truncatedReason).toBe("rate-limited");
202
- expect(out.retryAfterSeconds).toBe(20);
203
- expect(out.releasePrs).toEqual([]);
204
- });
205
-
206
- it("stops with rate-limited when the changelog lookup itself is rate limited", async () => {
207
- const octokit = octokitWith(async (route) => {
208
- if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")]);
209
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
210
- throw status(429, "Too Many Requests");
211
- });
212
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
213
- expect(out.truncatedReason).toBe("rate-limited");
214
- });
215
-
216
- it("omits retryAfterSeconds when GitHub sent none", async () => {
217
- const octokit = octokitWith(async () => { throw status(429, "Too Many Requests"); });
218
- const out = await reportReleasePrs(octokit, { owner: "cldmv" });
219
- expect(out.truncatedReason).toBe("rate-limited");
220
- expect(out).not.toHaveProperty("retryAfterSeconds");
221
- });
222
-
223
- it("propagates a non-404 error from the changelog lookup instead of calling it missing", async () => {
224
- const octokit = octokitWith(async (route) => {
225
- if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")]);
226
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
227
- throw status(500, "server exploded");
228
- });
229
- await expect(reportReleasePrs(octokit, { owner: "cldmv" })).rejects.toThrow("server exploded");
230
- });
231
-
232
- it("propagates a plain permission 403 on the search", async () => {
233
- const octokit = octokitWith(async () => { throw status(403, "Resource not accessible by personal access token"); });
234
- await expect(reportReleasePrs(octokit, { owner: "cldmv" })).rejects.toThrow("Resource not accessible");
235
- });
236
-
237
- it("clamps maxApiCalls to the hard cap", async () => {
238
- const octokit = octokitWith(async () => search([]));
239
- await reportReleasePrs(octokit, { owner: "cldmv", maxApiCalls: 999999 });
240
- expect(octokit.request).toHaveBeenCalledTimes(1);
241
- });
242
-
243
- it("advertises a valid schema", () => {
244
- expect(REPORT_RELEASE_PRS_SCHEMA.required).toEqual(["owner"]);
245
- expect(REPORT_RELEASE_PRS_SCHEMA.additionalProperties).toBe(false);
246
- });
247
- });