@cldmv/gh-broker 0.8.0 → 0.9.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 (92) hide show
  1. package/README.md +40 -10
  2. package/config.example.json +17 -1
  3. package/deploy/README.md +3 -2
  4. package/deploy/install.mjs +5 -3
  5. package/dist/index.mjs +4261 -1642
  6. package/dist/index.mjs.map +4 -4
  7. package/package.json +12 -2
  8. package/docs/TOOLS.md +0 -538
  9. package/docs/agent-user-model.md +0 -89
  10. package/docs/changelog/v0/v0.2.0.md +0 -26
  11. package/docs/changelog/v0/v0.2.1.md +0 -23
  12. package/docs/changelog/v0/v0.2.2.md +0 -23
  13. package/docs/changelog/v0/v0.2.3.md +0 -33
  14. package/docs/changelog/v0/v0.2.4.md +0 -25
  15. package/docs/changelog/v0/v0.2.5.md +0 -29
  16. package/docs/changelog/v0/v0.3.0.md +0 -84
  17. package/docs/changelog/v0/v0.3.1.md +0 -30
  18. package/docs/changelog/v0/v0.3.2.md +0 -24
  19. package/docs/changelog/v0/v0.3.3.md +0 -28
  20. package/docs/changelog/v0/v0.4.0.md +0 -58
  21. package/docs/changelog/v0/v0.4.1.md +0 -33
  22. package/docs/changelog/v0/v0.4.2.md +0 -45
  23. package/docs/changelog/v0/v0.5.0.md +0 -39
  24. package/docs/changelog/v0/v0.5.1.md +0 -28
  25. package/docs/changelog/v0/v0.6.0.md +0 -52
  26. package/docs/changelog/v0/v0.6.1.md +0 -48
  27. package/docs/changelog/v0/v0.7.0.md +0 -71
  28. package/docs/changelog/v0/v0.8.0.md +0 -115
  29. package/docs/deploy-architecture.md +0 -84
  30. package/src/appauth.ts +0 -56
  31. package/src/audit.ts +0 -34
  32. package/src/availability.ts +0 -42
  33. package/src/catalog.ts +0 -182
  34. package/src/config.ts +0 -157
  35. package/src/index.ts +0 -107
  36. package/src/paths.ts +0 -60
  37. package/src/router.ts +0 -299
  38. package/src/secrets.ts +0 -167
  39. package/src/server.ts +0 -91
  40. package/src/types.ts +0 -128
  41. package/src/upstream.ts +0 -85
  42. package/src/verbs/ghcr/archive.ts +0 -314
  43. package/src/verbs/ghcr/config.ts +0 -142
  44. package/src/verbs/ghcr/jobs.ts +0 -141
  45. package/src/verbs/ghcr/push.ts +0 -192
  46. package/src/verbs/ghcr/registry.ts +0 -275
  47. package/src/verbs/ghcr/tar.ts +0 -203
  48. package/src/verbs/ghcr_push.ts +0 -264
  49. package/src/verbs/issue_labels.ts +0 -329
  50. package/src/verbs/link_issue.ts +0 -84
  51. package/src/verbs/npm-registry.ts +0 -168
  52. package/src/verbs/npm-tarball.ts +0 -76
  53. package/src/verbs/npm_scaffold.ts +0 -325
  54. package/src/verbs/release-analysis.ts +0 -212
  55. package/src/verbs/repo-tier.ts +0 -90
  56. package/src/verbs/report_ci_status.ts +0 -358
  57. package/src/verbs/report_release_prs.ts +0 -238
  58. package/src/verbs/report_security_alerts.ts +0 -442
  59. package/src/verbs/report_unlabeled.ts +0 -194
  60. package/src/verbs/rest-client.ts +0 -181
  61. package/src/verbs/review_thread.ts +0 -138
  62. package/tests/appauth.test.vitest.mts +0 -76
  63. package/tests/audit.test.vitest.mts +0 -48
  64. package/tests/catalog.test.vitest.mts +0 -234
  65. package/tests/config-merge.test.vitest.mts +0 -325
  66. package/tests/config.test.vitest.mts +0 -388
  67. package/tests/credentials.test.vitest.mts +0 -127
  68. package/tests/ghcr-archive.test.vitest.mts +0 -439
  69. package/tests/ghcr-config.test.vitest.mts +0 -96
  70. package/tests/ghcr-push.test.vitest.mts +0 -526
  71. package/tests/ghcr-registry.test.vitest.mts +0 -206
  72. package/tests/helpers/ghcr-fixtures.mts +0 -373
  73. package/tests/install-args.test.vitest.mts +0 -152
  74. package/tests/issue-labels.test.vitest.mts +0 -362
  75. package/tests/npm-scaffold.test.vitest.mts +0 -479
  76. package/tests/npm-setup.test.vitest.mts +0 -196
  77. package/tests/paths.test.vitest.mts +0 -57
  78. package/tests/prompt.test.vitest.mts +0 -128
  79. package/tests/release-prs.test.vitest.mts +0 -247
  80. package/tests/rest-client.test.vitest.mts +0 -115
  81. package/tests/router.test.vitest.mts +0 -505
  82. package/tests/run-vitest.mjs +0 -51
  83. package/tests/secret-store.test.vitest.mts +0 -167
  84. package/tests/secrets.test.vitest.mts +0 -208
  85. package/tests/security-alerts.test.vitest.mts +0 -338
  86. package/tests/server.test.vitest.mts +0 -200
  87. package/tests/smoke.test.vitest.mts +0 -177
  88. package/tests/system.test.vitest.mts +0 -355
  89. package/tests/upstream.test.vitest.mts +0 -91
  90. package/tests/verb-helpers.test.vitest.mts +0 -377
  91. package/tests/verbs.test.vitest.mts +0 -624
  92. package/tsconfig.json +0 -21
@@ -1,377 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { reportSecurityAlerts } from "../src/verbs/report_security_alerts.ts";
3
- import { reportReleasePrs } from "../src/verbs/report_release_prs.ts";
4
- import { classifyRepo } from "../src/verbs/repo-tier.ts";
5
- import { CallBudget } from "../src/verbs/rest-client.ts";
6
- import { commitType, releaseCommitPrs, mentionedPrs, classifyOpenPr, assessFreshness, decideVerdict, analyzeRelease } from "../src/verbs/release-analysis.ts";
7
-
8
- type Handler = (route: string, params: any) => Promise<{ data: unknown; headers?: Record<string, string> }>;
9
- const octokitWith = (h: Handler): any => ({ request: vi.fn(h) });
10
- const status = (code: number) => Object.assign(new Error("x"), { status: code });
11
- const b64 = (t: string) => ({ data: { content: Buffer.from(t).toString("base64"), encoding: "base64" }, headers: {} });
12
-
13
- const dep = (n: number, repo: string, severity: string, over: Record<string, unknown> = {}) => ({
14
- number: n,
15
- html_url: "u",
16
- created_at: "t",
17
- repository: { full_name: `CLDMV/${repo}` },
18
- security_vulnerability: { severity },
19
- security_advisory: { summary: "s" },
20
- ...over
21
- });
22
-
23
- describe("report_security_alerts — summaryOnly / filters / roll-up", () => {
24
- const org = (alerts: unknown[]): Handler => async (route) => ({ data: route.includes("dependabot") ? alerts : [], headers: {} });
25
-
26
- it("rolls alerts up per repo, worst first, and counts severities", async () => {
27
- const o = octokitWith(org([dep(1, "a", "low"), dep(2, "b", "critical"), dep(3, "b", "high"), dep(4, "a", "medium"), dep(5, "a", "weird")]));
28
- const out = await reportSecurityAlerts(o, { owner: "cldmv" });
29
- expect(out.byRepo.map((r) => [r.repo, r.total, r.critical, r.high, r.medium, r.low])).toEqual([
30
- ["CLDMV/b", 2, 1, 1, 0, 0],
31
- ["CLDMV/a", 3, 0, 0, 1, 1]
32
- ]);
33
- expect(out.byRepo[0]).toMatchObject({ dependabot: 2, codeql: 0, secrets: 0 });
34
- });
35
-
36
- it("summaryOnly drops the alert list but keeps roll-up, counts and reports how many were left out", async () => {
37
- const o = octokitWith(org([dep(1, "a", "high"), dep(2, "a", "high")]));
38
- const out = await reportSecurityAlerts(o, { owner: "cldmv", summaryOnly: true });
39
- expect(out.alerts).toEqual([]);
40
- expect(out.alertsOmitted).toBe(2);
41
- expect(out.byRepo[0]).toMatchObject({ repo: "CLDMV/a", total: 2 });
42
- expect(out.counts.dependabot).toEqual({ high: 2 });
43
- });
44
-
45
- it("minSeverity keeps alerts at or above it and always keeps secret alerts", async () => {
46
- const o = octokitWith(async (route) => {
47
- if (route.includes("dependabot")) return { data: [dep(1, "a", "low"), dep(2, "a", "high"), dep(3, "a", "medium")], headers: {} };
48
- if (route.includes("secret-scanning")) return { data: [{ number: 9, html_url: "u", created_at: "t", repository: { full_name: "CLDMV/a" } }], headers: {} };
49
- return { data: [], headers: {} };
50
- });
51
- const out = await reportSecurityAlerts(o, { owner: "cldmv", minSeverity: "high" });
52
- expect(out.alerts.map((a) => `${a.type}:${a.number}`).sort()).toEqual(["dependabot:2", "secrets:9"]);
53
- const med = await reportSecurityAlerts(o, { owner: "cldmv", minSeverity: "medium" });
54
- expect(med.alerts.filter((a) => a.type === "dependabot").map((a) => a.number).sort()).toEqual([2, 3]);
55
- const low = await reportSecurityAlerts(o, { owner: "cldmv", minSeverity: "low" });
56
- expect(low.alerts.filter((a) => a.type === "dependabot")).toHaveLength(3);
57
- });
58
-
59
- it("repos filters by name, with or without the owner prefix, case-insensitively", async () => {
60
- const o = octokitWith(org([dep(1, "a", "high"), dep(2, "b", "high"), dep(3, "c", "high")]));
61
- const out = await reportSecurityAlerts(o, { owner: "cldmv", repos: ["CLDMV/A", "c"] });
62
- expect(out.byRepo.map((r) => r.repo).sort()).toEqual(["CLDMV/a", "CLDMV/c"]);
63
- });
64
-
65
- it("classify adds each repo's tier and signals", async () => {
66
- const o = octokitWith(async (route, p) => {
67
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "v4repo", "high"), dep(2, "old", "high")], headers: {} };
68
- if (route.startsWith("GET /orgs/")) return { data: [], headers: {} };
69
- if (route === "GET /repos/{owner}/{repo}") return { data: { default_branch: "master" }, headers: {} };
70
- if (route.endsWith("/contents/{path}")) {
71
- if (p.repo === "old") throw status(404);
72
- return { data: {}, headers: {} };
73
- }
74
- if (route.endsWith("/branches/{branch}")) return { data: { commit: { sha: p.branch === "dev" ? "d" : "m" } }, headers: {} };
75
- throw new Error(route);
76
- });
77
- const out = await reportSecurityAlerts(o, { owner: "cldmv", classify: true });
78
- const by = Object.fromEntries(out.byRepo.map((r) => [r.repo, r.tier]));
79
- expect(by).toEqual({ "CLDMV/v4repo": "dev-trunk", "CLDMV/old": "non-v4" });
80
- });
81
-
82
- it("classify resolves a repo name that has no owner prefix against the queried owner", async () => {
83
- const o = octokitWith(async (route, p) => {
84
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "x", "high", { repository: { full_name: "plain" } })], headers: {} };
85
- if (route.startsWith("GET /orgs/")) return { data: [], headers: {} };
86
- expect(p).toMatchObject({ owner: "cldmv", repo: "plain" });
87
- throw status(404);
88
- });
89
- const out = await reportSecurityAlerts(o, { owner: "cldmv", classify: true });
90
- expect(out.byRepo[0]).toMatchObject({ repo: "plain", tier: "non-v4" });
91
- });
92
-
93
- it("classify stops cleanly when the call budget runs out", async () => {
94
- const o = octokitWith(async (route) => {
95
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "a", "high")], headers: {} };
96
- if (route.startsWith("GET /orgs/")) return { data: [], headers: {} };
97
- return { data: { default_branch: "master" }, headers: {} };
98
- });
99
- const out = await reportSecurityAlerts(o, { owner: "cldmv", classify: true, maxApiCalls: 4 });
100
- expect(out.truncatedReason).toBe("max-api-calls");
101
- expect(out.byRepo[0]!.tier).toBeUndefined();
102
- });
103
-
104
- it("classify reports a rate limit and keeps what it has", async () => {
105
- const o = octokitWith(async (route) => {
106
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "a", "high")], headers: {} };
107
- if (route.startsWith("GET /orgs/")) return { data: [], headers: {} };
108
- throw Object.assign(new Error("x"), { status: 429 });
109
- });
110
- const out = await reportSecurityAlerts(o, { owner: "cldmv", classify: true });
111
- expect(out.truncatedReason).toBe("rate-limited");
112
- });
113
-
114
- it("classify propagates an unexpected error", async () => {
115
- const o = octokitWith(async (route) => {
116
- if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "a", "high")], headers: {} };
117
- if (route.startsWith("GET /orgs/")) return { data: [], headers: {} };
118
- throw status(500);
119
- });
120
- await expect(reportSecurityAlerts(o, { owner: "cldmv", classify: true })).rejects.toThrow();
121
- });
122
- });
123
-
124
- describe("classifyRepo", () => {
125
- const make = (opts: { workflow: boolean; next?: string; dev?: string; def?: string }) =>
126
- octokitWith(async (route, p) => {
127
- if (route === "GET /repos/{owner}/{repo}") return { data: { default_branch: "master" }, headers: {} };
128
- if (route.endsWith("/contents/{path}")) {
129
- if (!opts.workflow) throw status(404);
130
- return { data: {}, headers: {} };
131
- }
132
- const sha: Record<string, string | undefined> = { master: opts.def ?? "m", next: opts.next, dev: opts.dev };
133
- if (!sha[p.branch]) throw status(404);
134
- return { data: { commit: { sha: sha[p.branch] } }, headers: {} };
135
- });
136
- const run = (o: any) => classifyRepo(o, new CallBudget(50), "cldmv", "r");
137
-
138
- it("non-v4 when the release workflow is missing, even with next/hotfixes branches", async () => {
139
- const out = await run(make({ workflow: false, next: "m" }));
140
- expect(out).toEqual({ tier: "non-v4", signals: { v4Workflow: false, defaultBranch: "master" } });
141
- });
142
- it("v4 for a normal staging-flow repo (next ahead of master)", async () => {
143
- expect((await run(make({ workflow: true, next: "n" }))).tier).toBe("v4");
144
- });
145
- it("v4 when next equals master and there is no dev branch", async () => {
146
- expect((await run(make({ workflow: true, next: "m" }))).tier).toBe("v4");
147
- });
148
- it("dev-trunk when dev differs and next is scaffold-only (equal to master)", async () => {
149
- const out = await run(make({ workflow: true, next: "m", dev: "d" }));
150
- expect(out.tier).toBe("dev-trunk");
151
- expect(out.signals).toMatchObject({ dev: true, nextEqualsDefault: true, devDiffersFromDefault: true });
152
- });
153
- it("dev-trunk when next is missing entirely", async () => {
154
- expect((await run(make({ workflow: true, dev: "d" }))).tier).toBe("dev-trunk");
155
- });
156
- it("v4 when a dev branch exists but next carries real work", async () => {
157
- expect((await run(make({ workflow: true, next: "n", dev: "d" }))).tier).toBe("v4");
158
- });
159
- it("classifies a v4 repo whose info lacks a default branch, comparing nothing against it", async () => {
160
- const o = octokitWith(async (route, p) => {
161
- if (route === "GET /repos/{owner}/{repo}") return { data: {}, headers: {} };
162
- if (route.endsWith("/contents/{path}")) return { data: {}, headers: {} };
163
- if (p.branch === "next") return { data: { commit: { sha: "n" } }, headers: {} };
164
- throw status(404);
165
- });
166
- expect(await run(o)).toEqual({ tier: "v4", signals: { v4Workflow: true, next: true, dev: false } });
167
- });
168
- it("copes with a repo whose info can't be read", async () => {
169
- const o = octokitWith(async (route) => {
170
- if (route === "GET /repos/{owner}/{repo}") throw status(404);
171
- throw status(404);
172
- });
173
- expect((await run(o)).tier).toBe("non-v4");
174
- });
175
- it("propagates a non-404 error", async () => {
176
- await expect(run(octokitWith(async () => { throw status(500); }))).rejects.toThrow();
177
- });
178
- });
179
-
180
- describe("release-analysis helpers", () => {
181
- it("commitType reads conventional prefixes", () => {
182
- expect(commitType("fix(cjs)!: x")).toBe("fix");
183
- expect(commitType("Docs: x")).toBe("docs");
184
- expect(commitType("plain subject")).toBe("");
185
- });
186
-
187
- it("releaseCommitPrs takes PR numbers from the first line only and drops docs and bumps", () => {
188
- const out = releaseCommitPrs([
189
- "fix(a): thing (#10)\n\nbody mentions #99",
190
- "fix(a): thing (#10)",
191
- "docs: notes (#11)",
192
- "chore: bump version to 1.2.3",
193
- "ci: sync (#12)",
194
- "Merge pull request #13 from x/y",
195
- "Merge branch 'next' into foo",
196
- "fix(b): branch commit with no number",
197
- "deps: bump x (#14)"
198
- ]);
199
- expect(out).toEqual([
200
- { number: 10, title: "fix(a): thing", type: "fix" },
201
- { number: 12, title: "ci: sync", type: "ci" },
202
- { number: 13, title: "Merge pull request #13 from x/y", type: "" },
203
- { number: 14, title: "deps: bump x", type: "deps" }
204
- ]);
205
- });
206
-
207
- it("mentionedPrs finds #N and /pull/N", () => {
208
- expect([...mentionedPrs("see #4, [x](https://github.com/o/r/pull/7) and #12.")].sort((a, b) => a - b)).toEqual([4, 7, 12]);
209
- });
210
-
211
- it("classifyOpenPr sorts doc, dependency, feature and draft", () => {
212
- const base = { number: 1, html_url: "u" };
213
- expect(classifyOpenPr({ ...base, title: "docs: v1.2.3 release notes" })).toMatchObject({ kind: "doc", version: "1.2.3" });
214
- expect(classifyOpenPr({ ...base, title: "x", head: { ref: "docs/foo" } }).kind).toBe("doc");
215
- expect(classifyOpenPr({ ...base, title: "deps: bump" }).kind).toBe("dependency");
216
- expect(classifyOpenPr({ ...base, title: "x", labels: [{ name: "type: dependencies" }] }).kind).toBe("dependency");
217
- expect(classifyOpenPr({ ...base, title: "x", user: { login: "dependabot[bot]" } }).kind).toBe("dependency");
218
- expect(classifyOpenPr({ ...base, title: "feat: y" }).kind).toBe("feature");
219
- expect(classifyOpenPr({ ...base, title: "feat: y", draft: true }).kind).toBe("draft");
220
- expect(classifyOpenPr({ ...base, title: "docs: no version" }).version).toBeUndefined();
221
- expect(classifyOpenPr({ ...base, title: "feat: y", labels: [{}] }).kind).toBe("feature"); // a label entry with no name
222
- });
223
-
224
- const long = "# P v1.2.3 Changelog\n\n## Overview\n\n" + "text ".repeat(100);
225
- const commits = [
226
- { number: 1, title: "fix: a", type: "fix" },
227
- { number: 2, title: "ci: b", type: "ci" }
228
- ];
229
- it("assessFreshness: current, minor-gap, stale, version-drift, thin", () => {
230
- const readme = "### Latest: v1.2.3 (October 2026)";
231
- expect(assessFreshness("1.2.3", `${long} #1 #2`, commits, readme)).toMatchObject({ status: "current", readmeMentionsVersion: true, expectedPrs: 2 });
232
- expect(assessFreshness("1.2.3", `${long} #1`, commits, readme)).toMatchObject({ status: "minor-gap", missingInternal: [{ number: 2, title: "ci: b" }] });
233
- expect(assessFreshness("1.2.3", `${long} #2`, commits, readme)).toMatchObject({ status: "stale", missingUserFacing: [{ number: 1, title: "fix: a" }] });
234
- expect(assessFreshness("1.2.4", `${long} #1 #2`, commits, readme)).toMatchObject({ status: "version-drift", headingMatchesVersion: false });
235
- expect(assessFreshness("1.2.3", "# P v1.2.3 Changelog\n#1 #2", commits, undefined)).toMatchObject({ status: "thin", readmeMentionsVersion: false });
236
- });
237
-
238
- const fresh = (status: any, internal = 0): any => ({ status, expectedPrs: 1, missingUserFacing: [], missingInternal: new Array(internal).fill({ number: 1, title: "x" }), headingMatchesVersion: true, readmeMentionsVersion: true });
239
- const pr = (kind: any, number = 5, version?: string): any => ({ number, title: "t", url: "u", kind, ...(version ? { version } : {}) });
240
- it("decideVerdict words the reason for a changelog it has no freshness for", () => {
241
- expect(decideVerdict({ hasChangelog: true, unknownVersion: false, openPrs: [], version: "1.0.0" })).toMatchObject({ verdict: "update", reason: expect.stringContaining("changelog is out of date") });
242
- });
243
-
244
- it("decideVerdict walks the decision table", () => {
245
- expect(decideVerdict({ hasChangelog: false, unknownVersion: true, openPrs: [], version: null }).verdict).toBe("inspect");
246
- expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("current"), openPrs: [pr("feature")], version: "1.0.0" }).verdict).toBe("done");
247
- expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("minor-gap", 2), openPrs: [], version: "1.0.0" })).toMatchObject({ verdict: "done", reason: expect.stringContaining("2 internal") });
248
- expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [pr("doc", 7, "1.0.0"), pr("feature")], version: "1.0.0" })).toMatchObject({ verdict: "in-flight" });
249
- expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [pr("doc", 7, "0.9.0")], version: "1.0.0" })).toMatchObject({ verdict: "in-flight", docVersionMismatch: true });
250
- expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [pr("feature")], version: "1.0.0" }).verdict).toBe("wait");
251
- expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [pr("dependency"), pr("draft")], version: "1.0.0" }).verdict).toBe("gray");
252
- expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [], version: "1.0.0" }).verdict).toBe("write");
253
- expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("stale"), openPrs: [], version: "1.0.0" }).verdict).toBe("update");
254
- expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("version-drift"), openPrs: [pr("feature")], version: "1.0.0" }).verdict).toBe("wait");
255
- });
256
- });
257
-
258
- describe("report_release_prs analyze", () => {
259
- const hit = { number: 5, title: "release: v1.2.3 - x", html_url: "https://github.com/CLDMV/r/pull/5", repository_url: "https://api.github.com/repos/CLDMV/r" };
260
- const changelog = "# P v1.2.3 Changelog\n\n## Overview\n\n" + "text ".repeat(100) + " #10";
261
- const handler = (over: Partial<Record<string, any>> = {}): Handler => async (route, p) => {
262
- if (route in over) return over[route];
263
- if (route === "GET /search/issues") return { data: { total_count: 1, incomplete_results: false, items: [hit] }, headers: {} };
264
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return { data: { head: { ref: "next" }, base: { ref: "master" } }, headers: {} };
265
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [], headers: {} };
266
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits") return { data: [{ commit: { message: "fix: a (#10)" } }, { commit: { message: "fix: b (#11)" } }], headers: {} };
267
- if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
268
- if (p.path === "README.md") return b64("### Latest: v1.2.3");
269
- if (p.path.startsWith("docs/changelog/")) return b64(changelog);
270
- throw status(404);
271
- }
272
- throw new Error(route);
273
- };
274
-
275
- it("without analyze nothing extra is fetched or returned", async () => {
276
- const o = octokitWith(handler());
277
- const out = await reportReleasePrs(o, { owner: "cldmv" });
278
- expect(out.releasePrs[0]).not.toHaveProperty("analysis");
279
- expect(out).not.toHaveProperty("verdicts");
280
- expect(o.request.mock.calls.map((c: any[]) => c[0])).not.toContain("GET /repos/{owner}/{repo}/pulls/{pull_number}/commits");
281
- });
282
-
283
- it("flags a ready-but-stale changelog and tallies verdicts", async () => {
284
- const out = await reportReleasePrs(octokitWith(handler()), { owner: "cldmv", analyze: true });
285
- const a = out.releasePrs[0]!.analysis!;
286
- expect(out.releasePrs[0]!.status).toBe("ready");
287
- expect(a.freshness).toMatchObject({ status: "stale", missingUserFacing: [{ number: 11, title: "fix: b" }] });
288
- expect(a.verdict).toBe("update");
289
- expect(out.verdicts).toEqual({ update: 1 });
290
- });
291
-
292
- it("onlyMissing with analyze keeps a ready-but-stale PR and drops a done one", async () => {
293
- const stale = await reportReleasePrs(octokitWith(handler()), { owner: "cldmv", analyze: true, onlyMissing: true });
294
- expect(stale.releasePrs).toHaveLength(1);
295
- const done = handler({ "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits": { data: [{ commit: { message: "fix: a (#10)" } }], headers: {} } });
296
- const out = await reportReleasePrs(octokitWith(done), { owner: "cldmv", analyze: true, onlyMissing: true });
297
- expect(out.releasePrs).toEqual([]);
298
- expect(out.verdicts).toEqual({ done: 1 });
299
- });
300
-
301
- it("needs-changelog with an open feature PR waits; a doc PR is in flight", async () => {
302
- const wait = async (route: string, p: any) => {
303
- if (route === "GET /repos/{owner}/{repo}/contents/{path}") throw status(404);
304
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 20, title: "feat: z", html_url: "u", head: { ref: "feat/z" } }], headers: {} };
305
- return handler()(route, p);
306
- };
307
- const out = await reportReleasePrs(octokitWith(wait), { owner: "cldmv", analyze: true });
308
- expect(out.releasePrs[0]!.analysis).toMatchObject({ verdict: "wait", openPrs: [{ number: 20, kind: "feature" }] });
309
- const flight = async (route: string, p: any) => {
310
- if (route === "GET /repos/{owner}/{repo}/contents/{path}") throw status(404);
311
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 21, title: "docs: v1.2.3 changelog", html_url: "u", head: { ref: "docs/x" } }], headers: {} };
312
- return handler()(route, p);
313
- };
314
- const f = await reportReleasePrs(octokitWith(flight), { owner: "cldmv", analyze: true });
315
- expect(f.releasePrs[0]!.analysis).toMatchObject({ verdict: "in-flight" });
316
- });
317
-
318
- it("an unknown version is analysed as inspect without reading a changelog", async () => {
319
- const o = octokitWith(async (route, p) => {
320
- if (route === "GET /search/issues") return { data: { total_count: 1, incomplete_results: false, items: [{ ...hit, title: "Release soon" }] }, headers: {} };
321
- return handler()(route, p);
322
- });
323
- const out = await reportReleasePrs(o, { owner: "cldmv", analyze: true });
324
- expect(out.releasePrs[0]!.analysis!.verdict).toBe("inspect");
325
- });
326
-
327
- it("keeps the PR's status when the budget runs out mid-analysis", async () => {
328
- const out = await reportReleasePrs(octokitWith(handler()), { owner: "cldmv", analyze: true, maxApiCalls: 4 });
329
- expect(out.truncatedReason).toBe("max-api-calls");
330
- expect(out.releasePrs[0]!.status).toBe("ready");
331
- expect(out.releasePrs[0]!.analysis).toBeUndefined();
332
- });
333
-
334
- it("analyzeRelease tolerates a missing README and a non-base64 body", async () => {
335
- const o = octokitWith(async (route, p) => {
336
- if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
337
- if (p.path === "README.md") throw status(404);
338
- return { data: { content: changelog + " #11", encoding: "utf-8" }, headers: {} };
339
- }
340
- return handler()(route, p);
341
- });
342
- const a = await analyzeRelease(o, new CallBudget(50), { owner: "CLDMV", repo: "r", pull: 5, head: "next", version: "1.2.3", changelogPath: "docs/changelog/v1/v1.2.3.md" });
343
- expect(a.freshness).toMatchObject({ status: "current", readmeMentionsVersion: false });
344
- const empty = octokitWith(async (route, p) => (route === "GET /repos/{owner}/{repo}/contents/{path}" ? { data: {}, headers: {} } : handler()(route, p)));
345
- const b = await analyzeRelease(empty, new CallBudget(50), { owner: "CLDMV", repo: "r", pull: 5, head: "next", version: "1.2.3", changelogPath: "x" });
346
- expect(b.freshness!.status).toBe("version-drift");
347
- });
348
-
349
- it("flags a doc PR for another version, tolerates commits with no message, and stops on a rate limit while reading", async () => {
350
- const flight = async (route: string, p: any) => {
351
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 21, title: "docs: v9.9.9 changelog", html_url: "u", head: { ref: "docs/x" } }], headers: {} };
352
- if (route === "GET /repos/{owner}/{repo}/contents/{path}") throw status(404);
353
- return handler()(route, p);
354
- };
355
- const out = await reportReleasePrs(octokitWith(flight), { owner: "cldmv", analyze: true });
356
- expect(out.releasePrs[0]!.analysis).toMatchObject({ verdict: "in-flight", docVersionMismatch: true });
357
-
358
- const bare = handler({ "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits": { data: [{}, { commit: {} }], headers: {} } });
359
- const quiet = await reportReleasePrs(octokitWith(bare), { owner: "cldmv", analyze: true });
360
- expect(quiet.releasePrs[0]!.analysis).toMatchObject({ verdict: "done" });
361
-
362
- const limited = async (route: string, p: any) => {
363
- if (route === "GET /repos/{owner}/{repo}/contents/{path}" && p.path === "README.md") throw status(429);
364
- return handler()(route, p);
365
- };
366
- const cut = await reportReleasePrs(octokitWith(limited), { owner: "cldmv", analyze: true });
367
- expect(cut.truncatedReason).toBe("rate-limited");
368
- });
369
-
370
- it("propagates a non-404 error from a content read", async () => {
371
- const o = octokitWith(async (route, p) => {
372
- if (route === "GET /repos/{owner}/{repo}/contents/{path}" && p.path === "README.md") throw status(500);
373
- return handler()(route, p);
374
- });
375
- await expect(reportReleasePrs(o, { owner: "cldmv", analyze: true })).rejects.toThrow();
376
- });
377
- });