@cldmv/gh-broker 0.0.0-stage → 0.7.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 (107) hide show
  1. package/.configs/fix-headers.json +3 -0
  2. package/.configs/vitest.config.mjs +44 -0
  3. package/.github/dependabot.yml +111 -0
  4. package/.github/workflows/branch-retention.yml +44 -0
  5. package/.github/workflows/bundle-size.yml +52 -0
  6. package/.github/workflows/ci.yml +389 -0
  7. package/.github/workflows/cla.yml +66 -0
  8. package/.github/workflows/codeql.yml +87 -0
  9. package/.github/workflows/dependabot-auto-merge.yml +62 -0
  10. package/.github/workflows/dependabot-recreate.yml +67 -0
  11. package/.github/workflows/dependency-review.yml +46 -0
  12. package/.github/workflows/feature-pr.yml +75 -0
  13. package/.github/workflows/hotfix-redirector.yml +67 -0
  14. package/.github/workflows/hotfixes-release.yml +70 -0
  15. package/.github/workflows/labeler.yml +48 -0
  16. package/.github/workflows/master-commit-audit.yml +56 -0
  17. package/.github/workflows/member-auto-merge.yml +75 -0
  18. package/.github/workflows/next-release.yml +76 -0
  19. package/.github/workflows/next-reset.yml +50 -0
  20. package/.github/workflows/pr-notify.yml +44 -0
  21. package/.github/workflows/pr-title-normalizer.yml +52 -0
  22. package/.github/workflows/provenance.yml +51 -0
  23. package/.github/workflows/publish.yml +146 -0
  24. package/.github/workflows/release-merge.yml +104 -0
  25. package/.github/workflows/release-notify.yml +41 -0
  26. package/.github/workflows/scorecard.yml +64 -0
  27. package/.github/workflows/stale.yml +50 -0
  28. package/.github/workflows/tag-health.yml +70 -0
  29. package/.github/workflows/update-major-version-tags.yml +94 -0
  30. package/.github/workflows/v4-bootstrap.yml +115 -0
  31. package/.github/workflows/welcome.yml +42 -0
  32. package/LICENSE +202 -0
  33. package/README.md +185 -2
  34. package/config.example.json +281 -0
  35. package/deploy/README.md +92 -0
  36. package/deploy/gh-broker.service +57 -0
  37. package/deploy/install.mjs +344 -0
  38. package/deploy/install.sh +29 -0
  39. package/deploy/lib/config-merge.mjs +56 -0
  40. package/deploy/lib/prompt.mjs +98 -0
  41. package/deploy/lib/secret-store.mjs +114 -0
  42. package/deploy/lib/smoke.mjs +137 -0
  43. package/deploy/lib/system.mjs +222 -0
  44. package/dist/index.mjs +22970 -0
  45. package/dist/index.mjs.map +7 -0
  46. package/docs/TOOLS.md +347 -0
  47. package/docs/agent-user-model.md +89 -0
  48. package/docs/changelog/v0/v0.2.0.md +26 -0
  49. package/docs/changelog/v0/v0.2.1.md +23 -0
  50. package/docs/changelog/v0/v0.2.2.md +23 -0
  51. package/docs/changelog/v0/v0.2.3.md +33 -0
  52. package/docs/changelog/v0/v0.2.4.md +25 -0
  53. package/docs/changelog/v0/v0.2.5.md +29 -0
  54. package/docs/changelog/v0/v0.3.0.md +84 -0
  55. package/docs/changelog/v0/v0.3.1.md +30 -0
  56. package/docs/changelog/v0/v0.3.2.md +24 -0
  57. package/docs/changelog/v0/v0.3.3.md +28 -0
  58. package/docs/changelog/v0/v0.4.0.md +58 -0
  59. package/docs/changelog/v0/v0.4.1.md +33 -0
  60. package/docs/changelog/v0/v0.4.2.md +45 -0
  61. package/docs/changelog/v0/v0.5.0.md +39 -0
  62. package/docs/changelog/v0/v0.5.1.md +28 -0
  63. package/docs/changelog/v0/v0.6.0.md +52 -0
  64. package/docs/changelog/v0/v0.6.1.md +48 -0
  65. package/docs/changelog/v0/v0.7.0.md +71 -0
  66. package/docs/deploy-architecture.md +82 -0
  67. package/package.json +71 -5
  68. package/src/appauth.ts +56 -0
  69. package/src/audit.ts +34 -0
  70. package/src/catalog.ts +165 -0
  71. package/src/config.ts +130 -0
  72. package/src/index.ts +107 -0
  73. package/src/paths.ts +60 -0
  74. package/src/router.ts +258 -0
  75. package/src/secrets.ts +137 -0
  76. package/src/server.ts +91 -0
  77. package/src/types.ts +104 -0
  78. package/src/upstream.ts +85 -0
  79. package/src/verbs/issue_labels.ts +329 -0
  80. package/src/verbs/link_issue.ts +84 -0
  81. package/src/verbs/release-analysis.ts +210 -0
  82. package/src/verbs/repo-tier.ts +90 -0
  83. package/src/verbs/report_ci_status.ts +358 -0
  84. package/src/verbs/report_release_prs.ts +238 -0
  85. package/src/verbs/report_security_alerts.ts +442 -0
  86. package/src/verbs/report_unlabeled.ts +194 -0
  87. package/src/verbs/rest-client.ts +181 -0
  88. package/src/verbs/review_thread.ts +138 -0
  89. package/tests/appauth.test.vitest.mts +76 -0
  90. package/tests/audit.test.vitest.mts +48 -0
  91. package/tests/catalog.test.vitest.mts +180 -0
  92. package/tests/config-merge.test.vitest.mts +58 -0
  93. package/tests/config.test.vitest.mts +314 -0
  94. package/tests/issue-labels.test.vitest.mts +347 -0
  95. package/tests/paths.test.vitest.mts +52 -0
  96. package/tests/release-prs.test.vitest.mts +237 -0
  97. package/tests/rest-client.test.vitest.mts +115 -0
  98. package/tests/router.test.vitest.mts +399 -0
  99. package/tests/run-vitest.mjs +51 -0
  100. package/tests/secret-store.test.vitest.mts +106 -0
  101. package/tests/secrets.test.vitest.mts +155 -0
  102. package/tests/security-alerts.test.vitest.mts +328 -0
  103. package/tests/server.test.vitest.mts +20 -0
  104. package/tests/upstream.test.vitest.mts +91 -0
  105. package/tests/verb-helpers.test.vitest.mts +331 -0
  106. package/tests/verbs.test.vitest.mts +624 -0
  107. package/tsconfig.json +21 -0
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ const { connectMock, callToolMock, listToolsMock } = vi.hoisted(() => ({
4
+ connectMock: vi.fn(),
5
+ callToolMock: vi.fn(),
6
+ listToolsMock: vi.fn()
7
+ }));
8
+
9
+ vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
10
+ // Constructable mock (arrow fns can't be `new`-ed) — instances share the op mocks.
11
+ Client: vi.fn(function (this: any) {
12
+ this.connect = connectMock;
13
+ this.callTool = callToolMock;
14
+ this.listTools = listToolsMock;
15
+ })
16
+ }));
17
+ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
18
+ StreamableHTTPClientTransport: vi.fn(function () {})
19
+ }));
20
+
21
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
23
+ import { UpstreamPool } from "../src/upstream.ts";
24
+
25
+ const config: any = { listen: "unix:/x", upstream: "https://api.githubcopilot.com/mcp/", tools: [], owners: {} };
26
+
27
+ describe("UpstreamPool", () => {
28
+ beforeEach(() => {
29
+ connectMock.mockReset();
30
+ callToolMock.mockReset();
31
+ listToolsMock.mockReset();
32
+ vi.mocked(Client).mockClear();
33
+ vi.mocked(StreamableHTTPClientTransport).mockClear();
34
+ connectMock.mockResolvedValue(undefined);
35
+ });
36
+
37
+ it("forward connects with a Bearer token and calls the tool", async () => {
38
+ callToolMock.mockResolvedValue({ content: [{ type: "text", text: "ok" }] });
39
+ const pool = new UpstreamPool(config);
40
+ const out = await pool.forward("get_me", { a: 1 }, "TOK");
41
+
42
+ expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
43
+ expect(callToolMock).toHaveBeenCalledWith({ name: "get_me", arguments: { a: 1 } });
44
+ expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(new URL(config.upstream), {
45
+ requestInit: { headers: { Authorization: "Bearer TOK" } }
46
+ });
47
+ });
48
+
49
+ it("listToolsFor returns the upstream tools array", async () => {
50
+ listToolsMock.mockResolvedValue({ tools: [{ name: "get_me" }, { name: "list_issues" }] });
51
+ const pool = new UpstreamPool(config);
52
+ expect(await pool.listToolsFor("TOK")).toEqual([{ name: "get_me" }, { name: "list_issues" }]);
53
+ });
54
+
55
+ it("reuses a pooled client for the same token", async () => {
56
+ callToolMock.mockResolvedValue({ content: [] });
57
+ const pool = new UpstreamPool(config);
58
+ await pool.forward("get_me", {}, "TOK");
59
+ await pool.forward("list_issues", {}, "TOK");
60
+ expect(vi.mocked(Client)).toHaveBeenCalledTimes(1); // connection created once, then reused
61
+ });
62
+
63
+ it("evicts the stale client and retries ONCE on a failed op", async () => {
64
+ callToolMock.mockRejectedValueOnce(new Error("invalid session")).mockResolvedValueOnce({ content: [{ type: "text", text: "recovered" }] });
65
+ const pool = new UpstreamPool(config);
66
+ const out = await pool.forward("get_me", {}, "TOK");
67
+
68
+ expect(out).toEqual({ content: [{ type: "text", text: "recovered" }] });
69
+ expect(callToolMock).toHaveBeenCalledTimes(2);
70
+ expect(vi.mocked(Client)).toHaveBeenCalledTimes(2); // reconnected with a fresh client
71
+ });
72
+
73
+ it("propagates after exactly one retry on a persistently-failing op", async () => {
74
+ callToolMock.mockRejectedValue(new Error("bad token"));
75
+ const pool = new UpstreamPool(config);
76
+ await expect(pool.forward("get_me", {}, "TOK")).rejects.toThrow(/bad token/);
77
+ expect(callToolMock).toHaveBeenCalledTimes(2); // initial + one retry, then throws
78
+ });
79
+
80
+ it("does not cache a failed connect", async () => {
81
+ connectMock.mockRejectedValueOnce(new Error("connect fail"));
82
+ callToolMock.mockResolvedValue({ content: [] });
83
+ const pool = new UpstreamPool(config);
84
+
85
+ await expect(pool.forward("get_me", {}, "TOK")).rejects.toThrow(/connect fail/);
86
+ // connectMock now resolves (default) → a fresh connect is attempted, proving the
87
+ // failed one was evicted rather than cached.
88
+ await expect(pool.forward("get_me", {}, "TOK")).resolves.toEqual({ content: [] });
89
+ expect(vi.mocked(Client)).toHaveBeenCalledTimes(2);
90
+ });
91
+ });
@@ -0,0 +1,331 @@
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 stops cleanly when the call budget runs out", async () => {
83
+ const o = octokitWith(async (route) => {
84
+ if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "a", "high")], headers: {} };
85
+ if (route.startsWith("GET /orgs/")) return { data: [], headers: {} };
86
+ return { data: { default_branch: "master" }, headers: {} };
87
+ });
88
+ const out = await reportSecurityAlerts(o, { owner: "cldmv", classify: true, maxApiCalls: 4 });
89
+ expect(out.truncatedReason).toBe("max-api-calls");
90
+ expect(out.byRepo[0]!.tier).toBeUndefined();
91
+ });
92
+
93
+ it("classify reports a rate limit and keeps what it has", 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
+ throw Object.assign(new Error("x"), { status: 429 });
98
+ });
99
+ const out = await reportSecurityAlerts(o, { owner: "cldmv", classify: true });
100
+ expect(out.truncatedReason).toBe("rate-limited");
101
+ });
102
+
103
+ it("classify propagates an unexpected error", async () => {
104
+ const o = octokitWith(async (route) => {
105
+ if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "a", "high")], headers: {} };
106
+ if (route.startsWith("GET /orgs/")) return { data: [], headers: {} };
107
+ throw status(500);
108
+ });
109
+ await expect(reportSecurityAlerts(o, { owner: "cldmv", classify: true })).rejects.toThrow();
110
+ });
111
+ });
112
+
113
+ describe("classifyRepo", () => {
114
+ const make = (opts: { workflow: boolean; next?: string; dev?: string; def?: string }) =>
115
+ octokitWith(async (route, p) => {
116
+ if (route === "GET /repos/{owner}/{repo}") return { data: { default_branch: "master" }, headers: {} };
117
+ if (route.endsWith("/contents/{path}")) {
118
+ if (!opts.workflow) throw status(404);
119
+ return { data: {}, headers: {} };
120
+ }
121
+ const sha: Record<string, string | undefined> = { master: opts.def ?? "m", next: opts.next, dev: opts.dev };
122
+ if (!sha[p.branch]) throw status(404);
123
+ return { data: { commit: { sha: sha[p.branch] } }, headers: {} };
124
+ });
125
+ const run = (o: any) => classifyRepo(o, new CallBudget(50), "cldmv", "r");
126
+
127
+ it("non-v4 when the release workflow is missing, even with next/hotfixes branches", async () => {
128
+ const out = await run(make({ workflow: false, next: "m" }));
129
+ expect(out).toEqual({ tier: "non-v4", signals: { v4Workflow: false, defaultBranch: "master" } });
130
+ });
131
+ it("v4 for a normal staging-flow repo (next ahead of master)", async () => {
132
+ expect((await run(make({ workflow: true, next: "n" }))).tier).toBe("v4");
133
+ });
134
+ it("v4 when next equals master and there is no dev branch", async () => {
135
+ expect((await run(make({ workflow: true, next: "m" }))).tier).toBe("v4");
136
+ });
137
+ it("dev-trunk when dev differs and next is scaffold-only (equal to master)", async () => {
138
+ const out = await run(make({ workflow: true, next: "m", dev: "d" }));
139
+ expect(out.tier).toBe("dev-trunk");
140
+ expect(out.signals).toMatchObject({ dev: true, nextEqualsDefault: true, devDiffersFromDefault: true });
141
+ });
142
+ it("dev-trunk when next is missing entirely", async () => {
143
+ expect((await run(make({ workflow: true, dev: "d" }))).tier).toBe("dev-trunk");
144
+ });
145
+ it("v4 when a dev branch exists but next carries real work", async () => {
146
+ expect((await run(make({ workflow: true, next: "n", dev: "d" }))).tier).toBe("v4");
147
+ });
148
+ it("copes with a repo whose info can't be read", async () => {
149
+ const o = octokitWith(async (route) => {
150
+ if (route === "GET /repos/{owner}/{repo}") throw status(404);
151
+ throw status(404);
152
+ });
153
+ expect((await run(o)).tier).toBe("non-v4");
154
+ });
155
+ it("propagates a non-404 error", async () => {
156
+ await expect(run(octokitWith(async () => { throw status(500); }))).rejects.toThrow();
157
+ });
158
+ });
159
+
160
+ describe("release-analysis helpers", () => {
161
+ it("commitType reads conventional prefixes", () => {
162
+ expect(commitType("fix(cjs)!: x")).toBe("fix");
163
+ expect(commitType("Docs: x")).toBe("docs");
164
+ expect(commitType("plain subject")).toBe("");
165
+ });
166
+
167
+ it("releaseCommitPrs takes PR numbers from the first line only and drops docs and bumps", () => {
168
+ const out = releaseCommitPrs([
169
+ "fix(a): thing (#10)\n\nbody mentions #99",
170
+ "fix(a): thing (#10)",
171
+ "docs: notes (#11)",
172
+ "chore: bump version to 1.2.3",
173
+ "ci: sync (#12)",
174
+ "Merge pull request #13 from x/y",
175
+ "Merge branch 'next' into foo",
176
+ "fix(b): branch commit with no number",
177
+ "deps: bump x (#14)"
178
+ ]);
179
+ expect(out).toEqual([
180
+ { number: 10, title: "fix(a): thing", type: "fix" },
181
+ { number: 12, title: "ci: sync", type: "ci" },
182
+ { number: 13, title: "Merge pull request #13 from x/y", type: "" },
183
+ { number: 14, title: "deps: bump x", type: "deps" }
184
+ ]);
185
+ });
186
+
187
+ it("mentionedPrs finds #N and /pull/N", () => {
188
+ expect([...mentionedPrs("see #4, [x](https://github.com/o/r/pull/7) and #12.")].sort((a, b) => a - b)).toEqual([4, 7, 12]);
189
+ });
190
+
191
+ it("classifyOpenPr sorts doc, dependency, feature and draft", () => {
192
+ const base = { number: 1, html_url: "u" };
193
+ expect(classifyOpenPr({ ...base, title: "docs: v1.2.3 release notes" })).toMatchObject({ kind: "doc", version: "1.2.3" });
194
+ expect(classifyOpenPr({ ...base, title: "x", head: { ref: "docs/foo" } }).kind).toBe("doc");
195
+ expect(classifyOpenPr({ ...base, title: "deps: bump" }).kind).toBe("dependency");
196
+ expect(classifyOpenPr({ ...base, title: "x", labels: [{ name: "type: dependencies" }] }).kind).toBe("dependency");
197
+ expect(classifyOpenPr({ ...base, title: "x", user: { login: "dependabot[bot]" } }).kind).toBe("dependency");
198
+ expect(classifyOpenPr({ ...base, title: "feat: y" }).kind).toBe("feature");
199
+ expect(classifyOpenPr({ ...base, title: "feat: y", draft: true }).kind).toBe("draft");
200
+ expect(classifyOpenPr({ ...base, title: "docs: no version" }).version).toBeUndefined();
201
+ });
202
+
203
+ const long = "# P v1.2.3 Changelog\n\n## Overview\n\n" + "text ".repeat(100);
204
+ const commits = [
205
+ { number: 1, title: "fix: a", type: "fix" },
206
+ { number: 2, title: "ci: b", type: "ci" }
207
+ ];
208
+ it("assessFreshness: current, minor-gap, stale, version-drift, thin", () => {
209
+ const readme = "### Latest: v1.2.3 (October 2026)";
210
+ expect(assessFreshness("1.2.3", `${long} #1 #2`, commits, readme)).toMatchObject({ status: "current", readmeMentionsVersion: true, expectedPrs: 2 });
211
+ expect(assessFreshness("1.2.3", `${long} #1`, commits, readme)).toMatchObject({ status: "minor-gap", missingInternal: [{ number: 2, title: "ci: b" }] });
212
+ expect(assessFreshness("1.2.3", `${long} #2`, commits, readme)).toMatchObject({ status: "stale", missingUserFacing: [{ number: 1, title: "fix: a" }] });
213
+ expect(assessFreshness("1.2.4", `${long} #1 #2`, commits, readme)).toMatchObject({ status: "version-drift", headingMatchesVersion: false });
214
+ expect(assessFreshness("1.2.3", "# P v1.2.3 Changelog\n#1 #2", commits, undefined)).toMatchObject({ status: "thin", readmeMentionsVersion: false });
215
+ });
216
+
217
+ const fresh = (status: any, internal = 0): any => ({ status, expectedPrs: 1, missingUserFacing: [], missingInternal: new Array(internal).fill({ number: 1, title: "x" }), headingMatchesVersion: true, readmeMentionsVersion: true });
218
+ const pr = (kind: any, number = 5, version?: string): any => ({ number, title: "t", url: "u", kind, ...(version ? { version } : {}) });
219
+ it("decideVerdict walks the decision table", () => {
220
+ expect(decideVerdict({ hasChangelog: false, unknownVersion: true, openPrs: [], version: null }).verdict).toBe("inspect");
221
+ expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("current"), openPrs: [pr("feature")], version: "1.0.0" }).verdict).toBe("done");
222
+ expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("minor-gap", 2), openPrs: [], version: "1.0.0" })).toMatchObject({ verdict: "done", reason: expect.stringContaining("2 internal") });
223
+ expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [pr("doc", 7, "1.0.0"), pr("feature")], version: "1.0.0" })).toMatchObject({ verdict: "in-flight" });
224
+ expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [pr("doc", 7, "0.9.0")], version: "1.0.0" })).toMatchObject({ verdict: "in-flight", docVersionMismatch: true });
225
+ expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [pr("feature")], version: "1.0.0" }).verdict).toBe("wait");
226
+ expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [pr("dependency"), pr("draft")], version: "1.0.0" }).verdict).toBe("gray");
227
+ expect(decideVerdict({ hasChangelog: false, unknownVersion: false, openPrs: [], version: "1.0.0" }).verdict).toBe("write");
228
+ expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("stale"), openPrs: [], version: "1.0.0" }).verdict).toBe("update");
229
+ expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("version-drift"), openPrs: [pr("feature")], version: "1.0.0" }).verdict).toBe("wait");
230
+ });
231
+ });
232
+
233
+ describe("report_release_prs analyze", () => {
234
+ 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" };
235
+ const changelog = "# P v1.2.3 Changelog\n\n## Overview\n\n" + "text ".repeat(100) + " #10";
236
+ const handler = (over: Partial<Record<string, any>> = {}): Handler => async (route, p) => {
237
+ if (route in over) return over[route];
238
+ if (route === "GET /search/issues") return { data: { total_count: 1, incomplete_results: false, items: [hit] }, headers: {} };
239
+ if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return { data: { head: { ref: "next" }, base: { ref: "master" } }, headers: {} };
240
+ if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [], headers: {} };
241
+ if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits") return { data: [{ commit: { message: "fix: a (#10)" } }, { commit: { message: "fix: b (#11)" } }], headers: {} };
242
+ if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
243
+ if (p.path === "README.md") return b64("### Latest: v1.2.3");
244
+ if (p.path.startsWith("docs/changelog/")) return b64(changelog);
245
+ throw status(404);
246
+ }
247
+ throw new Error(route);
248
+ };
249
+
250
+ it("without analyze nothing extra is fetched or returned", async () => {
251
+ const o = octokitWith(handler());
252
+ const out = await reportReleasePrs(o, { owner: "cldmv" });
253
+ expect(out.releasePrs[0]).not.toHaveProperty("analysis");
254
+ expect(out).not.toHaveProperty("verdicts");
255
+ expect(o.request.mock.calls.map((c: any[]) => c[0])).not.toContain("GET /repos/{owner}/{repo}/pulls/{pull_number}/commits");
256
+ });
257
+
258
+ it("flags a ready-but-stale changelog and tallies verdicts", async () => {
259
+ const out = await reportReleasePrs(octokitWith(handler()), { owner: "cldmv", analyze: true });
260
+ const a = out.releasePrs[0]!.analysis!;
261
+ expect(out.releasePrs[0]!.status).toBe("ready");
262
+ expect(a.freshness).toMatchObject({ status: "stale", missingUserFacing: [{ number: 11, title: "fix: b" }] });
263
+ expect(a.verdict).toBe("update");
264
+ expect(out.verdicts).toEqual({ update: 1 });
265
+ });
266
+
267
+ it("onlyMissing with analyze keeps a ready-but-stale PR and drops a done one", async () => {
268
+ const stale = await reportReleasePrs(octokitWith(handler()), { owner: "cldmv", analyze: true, onlyMissing: true });
269
+ expect(stale.releasePrs).toHaveLength(1);
270
+ const done = handler({ "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits": { data: [{ commit: { message: "fix: a (#10)" } }], headers: {} } });
271
+ const out = await reportReleasePrs(octokitWith(done), { owner: "cldmv", analyze: true, onlyMissing: true });
272
+ expect(out.releasePrs).toEqual([]);
273
+ expect(out.verdicts).toEqual({ done: 1 });
274
+ });
275
+
276
+ it("needs-changelog with an open feature PR waits; a doc PR is in flight", async () => {
277
+ const wait = async (route: string, p: any) => {
278
+ if (route === "GET /repos/{owner}/{repo}/contents/{path}") throw status(404);
279
+ if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 20, title: "feat: z", html_url: "u", head: { ref: "feat/z" } }], headers: {} };
280
+ return handler()(route, p);
281
+ };
282
+ const out = await reportReleasePrs(octokitWith(wait), { owner: "cldmv", analyze: true });
283
+ expect(out.releasePrs[0]!.analysis).toMatchObject({ verdict: "wait", openPrs: [{ number: 20, kind: "feature" }] });
284
+ const flight = async (route: string, p: any) => {
285
+ if (route === "GET /repos/{owner}/{repo}/contents/{path}") throw status(404);
286
+ 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: {} };
287
+ return handler()(route, p);
288
+ };
289
+ const f = await reportReleasePrs(octokitWith(flight), { owner: "cldmv", analyze: true });
290
+ expect(f.releasePrs[0]!.analysis).toMatchObject({ verdict: "in-flight" });
291
+ });
292
+
293
+ it("an unknown version is analysed as inspect without reading a changelog", async () => {
294
+ const o = octokitWith(async (route, p) => {
295
+ if (route === "GET /search/issues") return { data: { total_count: 1, incomplete_results: false, items: [{ ...hit, title: "Release soon" }] }, headers: {} };
296
+ return handler()(route, p);
297
+ });
298
+ const out = await reportReleasePrs(o, { owner: "cldmv", analyze: true });
299
+ expect(out.releasePrs[0]!.analysis!.verdict).toBe("inspect");
300
+ });
301
+
302
+ it("keeps the PR's status when the budget runs out mid-analysis", async () => {
303
+ const out = await reportReleasePrs(octokitWith(handler()), { owner: "cldmv", analyze: true, maxApiCalls: 4 });
304
+ expect(out.truncatedReason).toBe("max-api-calls");
305
+ expect(out.releasePrs[0]!.status).toBe("ready");
306
+ expect(out.releasePrs[0]!.analysis).toBeUndefined();
307
+ });
308
+
309
+ it("analyzeRelease tolerates a missing README and a non-base64 body", async () => {
310
+ const o = octokitWith(async (route, p) => {
311
+ if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
312
+ if (p.path === "README.md") throw status(404);
313
+ return { data: { content: changelog + " #11", encoding: "utf-8" }, headers: {} };
314
+ }
315
+ return handler()(route, p);
316
+ });
317
+ 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" });
318
+ expect(a.freshness).toMatchObject({ status: "current", readmeMentionsVersion: false });
319
+ const empty = octokitWith(async (route, p) => (route === "GET /repos/{owner}/{repo}/contents/{path}" ? { data: {}, headers: {} } : handler()(route, p)));
320
+ const b = await analyzeRelease(empty, new CallBudget(50), { owner: "CLDMV", repo: "r", pull: 5, head: "next", version: "1.2.3", changelogPath: "x" });
321
+ expect(b.freshness!.status).toBe("version-drift");
322
+ });
323
+
324
+ it("propagates a non-404 error from a content read", async () => {
325
+ const o = octokitWith(async (route, p) => {
326
+ if (route === "GET /repos/{owner}/{repo}/contents/{path}" && p.path === "README.md") throw status(500);
327
+ return handler()(route, p);
328
+ });
329
+ await expect(reportReleasePrs(o, { owner: "cldmv", analyze: true })).rejects.toThrow();
330
+ });
331
+ });