@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,624 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { linkIssue, LINK_ISSUE_SCHEMA } from "../src/verbs/link_issue.ts";
3
- import {
4
- resolveReviewThread,
5
- replyReviewThread,
6
- RESOLVE_REVIEW_THREAD_SCHEMA,
7
- REPLY_REVIEW_THREAD_SCHEMA
8
- } from "../src/verbs/review_thread.ts";
9
- import { reportUnlabeled, REPORT_UNLABELED_SCHEMA } from "../src/verbs/report_unlabeled.ts";
10
- import { reportCiStatus, REPORT_CI_STATUS_SCHEMA } from "../src/verbs/report_ci_status.ts";
11
-
12
- describe("linkIssue", () => {
13
- let graphql: ReturnType<typeof vi.fn>;
14
- let octokit: any;
15
-
16
- beforeEach(() => {
17
- graphql = vi.fn();
18
- octokit = { graphql };
19
- });
20
-
21
- it("resolves node ids then creates the closing reference", async () => {
22
- graphql
23
- .mockResolvedValueOnce({ repository: { pullRequest: { id: "PR_abc" }, issue: { id: "I_xyz" } } })
24
- .mockResolvedValueOnce({ addClosingIssueReference: { pullRequest: { id: "PR_abc" } } });
25
- const out = await linkIssue(octokit, { owner: "cldmv", repo: "gh-broker", pull: 3, issue: 7 });
26
- expect(out).toEqual({ pullRequestId: "PR_abc", issueId: "I_xyz" });
27
- expect(graphql).toHaveBeenCalledTimes(2);
28
- // First: id resolution with the PR/issue numbers.
29
- expect(graphql.mock.calls[0]![1]).toEqual({ owner: "cldmv", repo: "gh-broker", pr: 3, issue: 7 });
30
- // Second: the mutation with the resolved node ids.
31
- expect(graphql.mock.calls[1]![1]).toEqual({ prId: "PR_abc", issueId: "I_xyz" });
32
- });
33
-
34
- it("throws when the PR is not found", async () => {
35
- graphql.mockResolvedValueOnce({ repository: { pullRequest: null, issue: { id: "I_1" } } });
36
- await expect(linkIssue(octokit, { owner: "cldmv", repo: "r", pull: 9, issue: 1 })).rejects.toThrow(
37
- /PR #9 not found in cldmv\/r/
38
- );
39
- expect(graphql).toHaveBeenCalledTimes(1); // never reaches the mutation
40
- });
41
-
42
- it("throws when the issue is not found", async () => {
43
- graphql.mockResolvedValueOnce({ repository: { pullRequest: { id: "PR_1" }, issue: null } });
44
- await expect(linkIssue(octokit, { owner: "cldmv", repo: "r", pull: 1, issue: 42 })).rejects.toThrow(
45
- /issue #42 not found in cldmv\/r/
46
- );
47
- });
48
-
49
- it("advertises a valid schema", () => {
50
- expect(LINK_ISSUE_SCHEMA.required).toEqual(["owner", "repo", "pull", "issue"]);
51
- expect(LINK_ISSUE_SCHEMA.additionalProperties).toBe(false);
52
- });
53
- });
54
-
55
- describe("resolveReviewThread", () => {
56
- let graphql: ReturnType<typeof vi.fn>;
57
- let octokit: any;
58
-
59
- beforeEach(() => {
60
- graphql = vi.fn();
61
- octokit = { graphql };
62
- });
63
-
64
- it("resolves a thread by default", async () => {
65
- graphql.mockResolvedValue({ resolveReviewThread: { thread: { id: "PRRT_1", isResolved: true } } });
66
- const out = await resolveReviewThread(octokit, { owner: "cldmv", threadId: "PRRT_1" });
67
- expect(out).toEqual({ threadId: "PRRT_1", isResolved: true });
68
- expect(graphql.mock.calls[0]![0]).toMatch(/resolveReviewThread/);
69
- expect(graphql.mock.calls[0]![0]).not.toMatch(/unresolveReviewThread/);
70
- expect(graphql.mock.calls[0]![1]).toEqual({ threadId: "PRRT_1" });
71
- });
72
-
73
- it("unresolves a thread when resolve is false", async () => {
74
- graphql.mockResolvedValue({ unresolveReviewThread: { thread: { id: "PRRT_2", isResolved: false } } });
75
- const out = await resolveReviewThread(octokit, { owner: "cldmv", threadId: "PRRT_2", resolve: false });
76
- expect(out).toEqual({ threadId: "PRRT_2", isResolved: false });
77
- expect(graphql.mock.calls[0]![0]).toMatch(/unresolveReviewThread/);
78
- });
79
-
80
- it("throws when no thread is returned", async () => {
81
- graphql.mockResolvedValue({});
82
- await expect(resolveReviewThread(octokit, { owner: "cldmv", threadId: "PRRT_x" })).rejects.toThrow(
83
- /no thread returned for "PRRT_x"/
84
- );
85
- });
86
- });
87
-
88
- describe("replyReviewThread", () => {
89
- let graphql: ReturnType<typeof vi.fn>;
90
- let octokit: any;
91
-
92
- beforeEach(() => {
93
- graphql = vi.fn();
94
- octokit = { graphql };
95
- });
96
-
97
- it("posts a reply and returns the comment id + url", async () => {
98
- graphql.mockResolvedValue({ addPullRequestReviewThreadReply: { comment: { id: "C_1", url: "https://gh/c/1" } } });
99
- const out = await replyReviewThread(octokit, { owner: "cldmv", threadId: "PRRT_1", body: "ack" });
100
- expect(out).toEqual({ commentId: "C_1", url: "https://gh/c/1" });
101
- expect(graphql.mock.calls[0]![1]).toEqual({ threadId: "PRRT_1", body: "ack" });
102
- });
103
-
104
- it("rejects an empty body without calling the API", async () => {
105
- await expect(replyReviewThread(octokit, { owner: "cldmv", threadId: "PRRT_1", body: " " })).rejects.toThrow(
106
- /body is required/
107
- );
108
- expect(graphql).not.toHaveBeenCalled();
109
- });
110
-
111
- it("throws when the reply mutation returns no comment", async () => {
112
- graphql.mockResolvedValue({ addPullRequestReviewThreadReply: { comment: null } });
113
- await expect(replyReviewThread(octokit, { owner: "cldmv", threadId: "PRRT_1", body: "hi" })).rejects.toThrow(
114
- /reply failed for "PRRT_1"/
115
- );
116
- });
117
-
118
- it("advertises valid schemas", () => {
119
- expect(RESOLVE_REVIEW_THREAD_SCHEMA.required).toEqual(["owner", "threadId"]);
120
- expect(REPLY_REVIEW_THREAD_SCHEMA.required).toEqual(["owner", "threadId", "body"]);
121
- });
122
- });
123
-
124
- describe("reportUnlabeled", () => {
125
- const labeledIssue = { number: 1, title: "labeled", html_url: "u1", created_at: "t1", labels: [{ name: "bug" }] };
126
- const unlabeledIssue = { number: 2, title: "unlabeled issue", html_url: "u2", created_at: "t2", labels: [] };
127
- const prViaIssuesEndpoint = { number: 3, title: "pr via issues", html_url: "u3", created_at: "t3", labels: [], pull_request: {} };
128
- const unlabeledPr = { number: 4, title: "unlabeled pr", html_url: "u4", created_at: "t4", labels: [], draft: false };
129
- const draftPr = { number: 5, title: "draft pr", html_url: "u5", created_at: "t5", labels: [], draft: true };
130
- const labeledPr = { number: 6, title: "labeled pr", html_url: "u6", created_at: "t6", labels: [{ name: "x" }], draft: false };
131
-
132
- function octokitWith(handler: (route: string, params: any) => Promise<{ data: unknown[]; headers?: Record<string, string> }>): any {
133
- return { request: vi.fn(handler) };
134
- }
135
-
136
- it("scans a single repo: excludes labeled items, PR-shaped issues, and draft PRs by default", async () => {
137
- const octokit = octokitWith(async (route) => {
138
- if (route === "GET /repos/{owner}/{repo}/issues") return { data: [labeledIssue, unlabeledIssue, prViaIssuesEndpoint], headers: {} };
139
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [unlabeledPr, draftPr, labeledPr], headers: {} };
140
- throw new Error(`unexpected route ${route}`);
141
- });
142
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "gh-broker" });
143
- expect(out.scanned).toEqual({ repos: 1, issues: 2, pulls: 3 });
144
- expect(out.unlabeled).toEqual([
145
- { repo: "cldmv/gh-broker", type: "issue", number: 2, title: "unlabeled issue", url: "u2", createdAt: "t2" },
146
- { repo: "cldmv/gh-broker", type: "pull", number: 4, title: "unlabeled pr", url: "u4", createdAt: "t4" }
147
- ]);
148
- expect(out.truncated).toBeUndefined();
149
- });
150
-
151
- it("includes draft PRs when includeDrafts is true", async () => {
152
- const octokit = octokitWith(async (route) => {
153
- if (route === "GET /repos/{owner}/{repo}/issues") return { data: [], headers: {} };
154
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [unlabeledPr, draftPr], headers: {} };
155
- throw new Error(`unexpected route ${route}`);
156
- });
157
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "pulls", includeDrafts: true });
158
- expect(out.unlabeled.map((u) => u.number)).toEqual([4, 5]);
159
- });
160
-
161
- it("enumerates org repos and skips archived ones when repo is omitted", async () => {
162
- const octokit = octokitWith(async (route, params) => {
163
- if (route === "GET /orgs/{owner}/repos") {
164
- expect(params.owner).toBe("cldmv");
165
- return { data: [{ name: "a" }, { name: "b", archived: true }, { name: "c" }], headers: {} };
166
- }
167
- if (route === "GET /repos/{owner}/{repo}/issues") return { data: [], headers: {} };
168
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [], headers: {} };
169
- throw new Error(`unexpected route ${route}`);
170
- });
171
- const out = await reportUnlabeled(octokit, { owner: "cldmv" });
172
- expect(out.scanned.repos).toBe(2);
173
- });
174
-
175
- it("falls back to the user-repos endpoint when the org endpoint 404s", async () => {
176
- const octokit = octokitWith(async (route, params) => {
177
- if (route === "GET /orgs/{owner}/repos") {
178
- const err = Object.assign(new Error("Not Found"), { status: 404 });
179
- throw err;
180
- }
181
- if (route === "GET /users/{username}/repos") {
182
- expect(params.username).toBe("someuser");
183
- return { data: [{ name: "solo" }], headers: {} };
184
- }
185
- if (route === "GET /repos/{owner}/{repo}/issues") return { data: [], headers: {} };
186
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [], headers: {} };
187
- throw new Error(`unexpected route ${route}`);
188
- });
189
- const out = await reportUnlabeled(octokit, { owner: "someuser" });
190
- expect(out.scanned.repos).toBe(1);
191
- });
192
-
193
- it("caps results at perRepoLimit even when more items come back in one page", async () => {
194
- const octokit = octokitWith(async (route) => {
195
- if (route === "GET /repos/{owner}/{repo}/issues") return { data: [unlabeledIssue, { ...unlabeledIssue, number: 20 }], headers: {} };
196
- throw new Error(`unexpected route ${route}`);
197
- });
198
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "issues", perRepoLimit: 1 });
199
- expect(out.scanned.issues).toBe(1);
200
- expect(out.unlabeled).toHaveLength(1);
201
- });
202
-
203
- it("stops and reports truncated when remaining drops at/below 2% of the token's own limit (5000/hr PAT)", async () => {
204
- const octokit = octokitWith(async (route) => {
205
- if (route === "GET /repos/{owner}/{repo}/issues") {
206
- return { data: [unlabeledIssue], headers: { "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "50" } };
207
- }
208
- throw new Error(`unexpected route ${route}`);
209
- });
210
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "issues" });
211
- expect(out.truncated).toBe(true);
212
- expect(out.truncatedReason).toBe("rate-limited");
213
- expect(out.retryAfterSeconds).toBeUndefined();
214
- expect(out.unlabeled).toEqual([]);
215
- });
216
-
217
- it("does NOT stop when remaining is comfortably above 2% of a 5000/hr limit", async () => {
218
- const octokit = octokitWith(async (route) => {
219
- if (route === "GET /repos/{owner}/{repo}/issues") {
220
- return { data: [unlabeledIssue], headers: { "x-ratelimit-limit": "5000", "x-ratelimit-remaining": "4800" } };
221
- }
222
- throw new Error(`unexpected route ${route}`);
223
- });
224
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "issues" });
225
- expect(out.truncated).toBeUndefined();
226
- expect(out.unlabeled).toHaveLength(1);
227
- });
228
-
229
- it("uses the absolute floor for a low-limit token (e.g. unauthenticated, 60/hr) instead of a near-zero ratio", async () => {
230
- const octokit = octokitWith(async (route) => {
231
- if (route === "GET /repos/{owner}/{repo}/issues") {
232
- // 2% of 60 is ~1, but the absolute floor (20) takes over — 15 remaining still trips it.
233
- return { data: [unlabeledIssue], headers: { "x-ratelimit-limit": "60", "x-ratelimit-remaining": "15" } };
234
- }
235
- throw new Error(`unexpected route ${route}`);
236
- });
237
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "issues" });
238
- expect(out.truncated).toBe(true);
239
- expect(out.truncatedReason).toBe("rate-limited");
240
- });
241
-
242
- it("falls back to the absolute floor when x-ratelimit-limit is missing", async () => {
243
- const octokit = octokitWith(async (route) => {
244
- if (route === "GET /repos/{owner}/{repo}/issues") return { data: [unlabeledIssue], headers: { "x-ratelimit-remaining": "10" } };
245
- throw new Error(`unexpected route ${route}`);
246
- });
247
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "issues" });
248
- expect(out.truncated).toBe(true);
249
- expect(out.truncatedReason).toBe("rate-limited");
250
- });
251
-
252
- it("stops and reports truncated with retryAfterSeconds on a 403", async () => {
253
- const octokit = octokitWith(async (route) => {
254
- if (route === "GET /repos/{owner}/{repo}/issues") {
255
- throw Object.assign(new Error("rate limited"), { status: 403, response: { headers: { "retry-after": "30" } } });
256
- }
257
- throw new Error(`unexpected route ${route}`);
258
- });
259
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "issues" });
260
- expect(out.truncated).toBe(true);
261
- expect(out.truncatedReason).toBe("rate-limited");
262
- expect(out.retryAfterSeconds).toBe(30);
263
- });
264
-
265
- it("stops on a 403 with no Retry-After header and omits retryAfterSeconds", async () => {
266
- const octokit = octokitWith(async (route) => {
267
- if (route === "GET /repos/{owner}/{repo}/issues") throw Object.assign(new Error("rate limited"), { status: 403 });
268
- throw new Error(`unexpected route ${route}`);
269
- });
270
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "issues" });
271
- expect(out.truncated).toBe(true);
272
- expect(out.truncatedReason).toBe("rate-limited");
273
- expect(out.retryAfterSeconds).toBeUndefined();
274
- });
275
-
276
- it("stops on a rate limit hit during repo enumeration without falling back to the user endpoint", async () => {
277
- const octokit = octokitWith(async (route) => {
278
- if (route === "GET /orgs/{owner}/repos") throw Object.assign(new Error("rate limited"), { status: 429 });
279
- throw new Error(`unexpected route ${route}`);
280
- });
281
- const out = await reportUnlabeled(octokit, { owner: "cldmv" });
282
- expect(out.truncated).toBe(true);
283
- expect(out.truncatedReason).toBe("rate-limited");
284
- expect(octokit.request).not.toHaveBeenCalledWith("GET /users/{username}/repos", expect.anything());
285
- });
286
-
287
- it("propagates a non-404 error during repo enumeration without falling back to the user endpoint", async () => {
288
- const octokit = octokitWith(async (route) => {
289
- if (route === "GET /orgs/{owner}/repos") throw Object.assign(new Error("server exploded"), { status: 500 });
290
- throw new Error(`unexpected route ${route}`);
291
- });
292
- await expect(reportUnlabeled(octokit, { owner: "cldmv" })).rejects.toThrow(/server exploded/);
293
- expect(octokit.request).not.toHaveBeenCalledWith("GET /users/{username}/repos", expect.anything());
294
- });
295
-
296
- it("stops and reports truncated reason max-api-calls when the call budget is exhausted", async () => {
297
- const octokit = octokitWith(async (route) => {
298
- if (route === "GET /repos/{owner}/{repo}/issues") return { data: [], headers: {} };
299
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [], headers: {} };
300
- throw new Error(`unexpected route ${route}`);
301
- });
302
- const out = await reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "both", maxApiCalls: 1 });
303
- expect(out.truncated).toBe(true);
304
- expect(out.truncatedReason).toBe("max-api-calls");
305
- });
306
-
307
- it("advances to a second page when a page comes back exactly full", async () => {
308
- const fullPage = Array.from({ length: 100 }, (_, i) => ({ name: `repo${i}` }));
309
- const octokit = octokitWith(async (route, params) => {
310
- if (route === "GET /orgs/{owner}/repos") return params.page === 2 ? { data: [{ name: "last" }], headers: {} } : { data: fullPage, headers: {} };
311
- if (route === "GET /repos/{owner}/{repo}/issues") return { data: [], headers: {} };
312
- throw new Error(`unexpected route ${route}`);
313
- });
314
- const out = await reportUnlabeled(octokit, { owner: "cldmv", target: "issues", maxRepos: 150 });
315
- expect(out.scanned.repos).toBe(101);
316
- });
317
-
318
- it("re-throws an error that is neither a rate limit nor a budget exhaustion", async () => {
319
- const octokit = octokitWith(async (route) => {
320
- if (route === "GET /repos/{owner}/{repo}/issues") throw new Error("boom");
321
- throw new Error(`unexpected route ${route}`);
322
- });
323
- await expect(reportUnlabeled(octokit, { owner: "cldmv", repo: "r", target: "issues" })).rejects.toThrow(/boom/);
324
- });
325
-
326
- it("advertises a valid schema", () => {
327
- expect(REPORT_UNLABELED_SCHEMA.required).toEqual(["owner"]);
328
- expect(REPORT_UNLABELED_SCHEMA.additionalProperties).toBe(false);
329
- });
330
- });
331
-
332
- describe("reportCiStatus", () => {
333
- function octokitWith(handler: (route: string, params: any) => Promise<{ data: unknown; headers?: Record<string, string> }>): any {
334
- return { request: vi.fn(handler) };
335
- }
336
- const pr = (sha: string) => ({ data: { head: { sha } } });
337
- const runs = (workflow_runs: unknown[]) => ({ data: { workflow_runs }, headers: {} });
338
- const jobs = (jobs_: unknown[]) => ({ data: { jobs: jobs_ }, headers: {} });
339
- const run = (over: Partial<{ id: number; name: string; status: string; conclusion: string | null; html_url: string }> = {}) => ({
340
- id: 1,
341
- name: "CI",
342
- status: "completed",
343
- conclusion: "success",
344
- html_url: "https://gh/run/1",
345
- ...over
346
- });
347
- const job = (over: Partial<{ id: number; name: string; status: string; conclusion: string | null; html_url: string; steps: unknown[] }> = {}) => ({
348
- id: 100,
349
- name: "test",
350
- status: "completed",
351
- conclusion: "success",
352
- html_url: "https://gh/job/100",
353
- steps: [],
354
- ...over
355
- });
356
- const step = (over: Partial<{ name: string; status: string; conclusion: string | null; number: number; started_at: string | null; completed_at: string | null }> = {}) => ({
357
- name: "npm test",
358
- status: "completed",
359
- conclusion: "success",
360
- number: 1,
361
- started_at: "2026-01-01T00:00:00Z",
362
- completed_at: "2026-01-01T00:00:05Z",
363
- ...over
364
- });
365
-
366
- it("tier 0: counts passed/failed/pending, no failures key, no job calls made", async () => {
367
- const octokit = octokitWith(async (route) => {
368
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
369
- if (route === "GET /repos/{owner}/{repo}/actions/runs") {
370
- return runs([run({ id: 1, conclusion: "success" }), run({ id: 2, conclusion: "failure" }), run({ id: 3, status: "in_progress", conclusion: null })]);
371
- }
372
- throw new Error(`unexpected route ${route}`);
373
- });
374
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5 });
375
- expect(out).toEqual({ pull: 5, summary: { passed: 1, failed: 1, pending: 1 } });
376
- expect(octokit.request).not.toHaveBeenCalledWith("GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs", expect.anything());
377
- });
378
-
379
- it("tier 1: includes only the failing job of a failing run, with workflow/job/run info", async () => {
380
- const octokit = octokitWith(async (route, params) => {
381
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
382
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ id: 9, name: "CI", conclusion: "failure" })]);
383
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") {
384
- expect(params.run_id).toBe(9);
385
- return jobs([job({ id: 200, name: "lint", conclusion: "success" }), job({ id: 201, name: "test", conclusion: "failure" })]);
386
- }
387
- throw new Error(`unexpected route ${route}`);
388
- });
389
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 1 });
390
- expect(out.summary).toEqual({ passed: 0, failed: 1, pending: 0 });
391
- expect(out.failures).toEqual([{ workflow: "CI", job: "test", runId: 9, runUrl: "https://gh/run/1", conclusion: "failure" }]);
392
- });
393
-
394
- it("tier 2: attaches only the failing steps of a failing job, with computed durationMs", async () => {
395
- const octokit = octokitWith(async (route) => {
396
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
397
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ conclusion: "failure" })]);
398
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") {
399
- return jobs([
400
- job({
401
- conclusion: "failure",
402
- steps: [step({ name: "setup", conclusion: "success" }), step({ name: "npm run coverage", number: 4, conclusion: "failure", started_at: "2026-01-01T00:00:00Z", completed_at: "2026-01-01T00:00:08.213Z" })]
403
- })
404
- ]);
405
- }
406
- throw new Error(`unexpected route ${route}`);
407
- });
408
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 2 });
409
- expect(out.failures?.[0]?.steps).toEqual([{ name: "npm run coverage", number: 4, conclusion: "failure", durationMs: 8213 }]);
410
- });
411
-
412
- it("tier 3: attaches the failing job's log via the injected fetchFailedJobLogs, one call per failing run", async () => {
413
- const octokit = octokitWith(async (route) => {
414
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
415
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ id: 9, conclusion: "failure" })]);
416
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") return jobs([job({ id: 555, conclusion: "failure" }), job({ id: 556, conclusion: "success" })]);
417
- throw new Error(`unexpected route ${route}`);
418
- });
419
- const fetchFailedJobLogs = vi.fn(async (runId: number, tailLines: number) => {
420
- expect(runId).toBe(9);
421
- expect(tailLines).toBe(5);
422
- return new Map([[555, "tailed log content"]]);
423
- });
424
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 3, logLines: 5 }, fetchFailedJobLogs);
425
- expect(fetchFailedJobLogs).toHaveBeenCalledTimes(1); // once per failing RUN, not per job
426
- expect(out.failures?.[0]?.logTail).toBe("tailed log content");
427
- });
428
-
429
- it("tier 3: leaves logTail unset for a job with no entry in the fetched map", async () => {
430
- const octokit = octokitWith(async (route) => {
431
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
432
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ conclusion: "failure" })]);
433
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") return jobs([job({ id: 555, conclusion: "failure" })]);
434
- throw new Error(`unexpected route ${route}`);
435
- });
436
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 3 }, async () => new Map());
437
- expect(out.failures?.[0]?.logTail).toBeUndefined();
438
- });
439
-
440
- it("tier 3: skips fetching logs entirely when no fetcher is provided", async () => {
441
- const octokit = octokitWith(async (route) => {
442
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
443
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ conclusion: "failure" })]);
444
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") return jobs([job({ id: 555, conclusion: "failure" })]);
445
- throw new Error(`unexpected route ${route}`);
446
- });
447
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 3 });
448
- expect(out.failures?.[0]?.logTail).toBeUndefined();
449
- });
450
-
451
- it("scans every open PR when pull is omitted, including only PRs with a failure", async () => {
452
- const octokit = octokitWith(async (route, params) => {
453
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 1, head: { sha: "a" } }, { number: 2, head: { sha: "b" } }], headers: {} };
454
- if (route === "GET /repos/{owner}/{repo}/actions/runs") {
455
- return runs(params.head_sha === "a" ? [run({ conclusion: "success" })] : [run({ conclusion: "failure" })]);
456
- }
457
- throw new Error(`unexpected route ${route}`);
458
- });
459
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r" });
460
- expect(out).toEqual({ scanned: { pulls: 2 }, results: [{ pull: 2, summary: { passed: 0, failed: 1, pending: 0 } }] });
461
- });
462
-
463
- it("flags truncated:max-pulls when the open-PR list is exactly at the cap", async () => {
464
- const octokit = octokitWith(async (route) => {
465
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 1, head: { sha: "a" } }, { number: 2, head: { sha: "b" } }], headers: {} };
466
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ conclusion: "success" })]);
467
- throw new Error(`unexpected route ${route}`);
468
- });
469
- const out = (await reportCiStatus(octokit, { owner: "cldmv", repo: "r", maxPulls: 2 })) as any;
470
- expect(out.truncated).toBe(true);
471
- expect(out.truncatedReason).toBe("max-pulls");
472
- });
473
-
474
- it("stops mid-scan on a rate limit with no failures collected yet, single-pull mode", async () => {
475
- const octokit = octokitWith(async (route) => {
476
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
477
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ id: 1, conclusion: "failure" }), run({ id: 2, conclusion: "failure" })]);
478
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") {
479
- throw Object.assign(new Error("rate limited"), { status: 403 });
480
- }
481
- throw new Error(`unexpected route ${route}`);
482
- });
483
- const out = (await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 1 })) as any;
484
- expect(out.truncated).toBe(true);
485
- expect(out.truncatedReason).toBe("rate-limited");
486
- expect(out.summary.failed).toBe(2);
487
- expect(out.failures).toBeUndefined();
488
- });
489
-
490
- it("stops mid-scan on a rate limit but preserves failures already collected from an earlier run", async () => {
491
- const octokit = octokitWith(async (route, params) => {
492
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
493
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ id: 1, conclusion: "failure" }), run({ id: 2, conclusion: "failure" })]);
494
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") {
495
- if (params.run_id === 1) return jobs([job({ id: 201, name: "test", conclusion: "failure" })]);
496
- throw Object.assign(new Error("rate limited"), { status: 403 });
497
- }
498
- throw new Error(`unexpected route ${route}`);
499
- });
500
- const out = (await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 1 })) as any;
501
- expect(out.truncated).toBe(true);
502
- expect(out.truncatedReason).toBe("rate-limited");
503
- expect(out.failures).toEqual([{ workflow: "CI", job: "test", runId: 1, runUrl: "https://gh/run/1", conclusion: "failure" }]);
504
- });
505
-
506
- it("reports 'unknown' for a null job/step conclusion, and defaults to no steps when a job has none listed", async () => {
507
- const jobNoSteps = job({ conclusion: null });
508
- delete (jobNoSteps as any).steps;
509
- const octokit = octokitWith(async (route) => {
510
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
511
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ conclusion: "failure" })]);
512
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") return jobs([jobNoSteps]);
513
- throw new Error(`unexpected route ${route}`);
514
- });
515
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 2 });
516
- expect(out.failures).toEqual([{ workflow: "CI", job: "test", runId: 1, runUrl: "https://gh/run/1", conclusion: "unknown", steps: [] }]);
517
- });
518
-
519
- it("reports a null step conclusion as 'unknown' and a 0ms duration when timestamps are missing", async () => {
520
- const octokit = octokitWith(async (route) => {
521
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
522
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ conclusion: "failure" })]);
523
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") {
524
- return jobs([job({ conclusion: "failure", steps: [step({ conclusion: null, started_at: null, completed_at: null })] })]);
525
- }
526
- throw new Error(`unexpected route ${route}`);
527
- });
528
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 2 });
529
- expect(out.failures?.[0]?.steps).toEqual([{ name: "npm test", number: 1, conclusion: "unknown", durationMs: 0 }]);
530
- });
531
-
532
- it("surfaces retryAfterSeconds on a single-pull rate limit that includes a Retry-After header", async () => {
533
- const octokit = octokitWith(async (route) => {
534
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") throw Object.assign(new Error("nope"), { status: 429, response: { headers: { "retry-after": "45" } } });
535
- throw new Error(`unexpected route ${route}`);
536
- });
537
- const out = (await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5 })) as any;
538
- expect(out.retryAfterSeconds).toBe(45);
539
- });
540
-
541
- it("surfaces retryAfterSeconds on a multi-pull rate limit that includes a Retry-After header", async () => {
542
- const octokit = octokitWith(async (route) => {
543
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 1, head: { sha: "a" } }], headers: {} };
544
- if (route === "GET /repos/{owner}/{repo}/actions/runs") throw Object.assign(new Error("nope"), { status: 429, response: { headers: { "retry-after": "12" } } });
545
- throw new Error(`unexpected route ${route}`);
546
- });
547
- const out = (await reportCiStatus(octokit, { owner: "cldmv", repo: "r" })) as any;
548
- expect(out.retryAfterSeconds).toBe(12);
549
- });
550
-
551
- it("stops the whole multi-pull scan (not just one PR) when a PR's scan hits the call budget", async () => {
552
- const octokit = octokitWith(async (route) => {
553
- if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 1, head: { sha: "a" } }, { number: 2, head: { sha: "b" } }], headers: {} };
554
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ conclusion: "success" })]);
555
- throw new Error(`unexpected route ${route}`);
556
- });
557
- const out = (await reportCiStatus(octokit, { owner: "cldmv", repo: "r", maxApiCalls: 2 })) as any;
558
- // pulls-list call (1) + PR#1's runs call (2) exhaust the budget before PR#2 is ever scanned.
559
- expect(octokit.request).toHaveBeenCalledTimes(2);
560
- expect(out.scanned.pulls).toBe(1);
561
- expect(out.truncated).toBe(true);
562
- expect(out.truncatedReason).toBe("max-api-calls");
563
- });
564
-
565
- it("returns a truncated result immediately when the PR lookup itself is rate-limited", async () => {
566
- const octokit = octokitWith(async (route) => {
567
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") throw Object.assign(new Error("nope"), { status: 429 });
568
- throw new Error(`unexpected route ${route}`);
569
- });
570
- const out = (await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5 })) as any;
571
- expect(out).toMatchObject({ pull: 5, summary: { passed: 0, failed: 0, pending: 0 }, truncated: true, truncatedReason: "rate-limited" });
572
- });
573
-
574
- it("returns an empty truncated result when the open-PR list itself is rate-limited", async () => {
575
- const octokit = octokitWith(async (route) => {
576
- if (route === "GET /repos/{owner}/{repo}/pulls") throw Object.assign(new Error("nope"), { status: 429 });
577
- throw new Error(`unexpected route ${route}`);
578
- });
579
- const out = (await reportCiStatus(octokit, { owner: "cldmv", repo: "r" })) as any;
580
- expect(out).toMatchObject({ scanned: { pulls: 0 }, results: [], truncated: true, truncatedReason: "rate-limited" });
581
- });
582
-
583
- it("re-throws a genuine error (not a rate limit) from the PR lookup", async () => {
584
- const octokit = octokitWith(async (route) => {
585
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") throw Object.assign(new Error("not found"), { status: 404 });
586
- throw new Error(`unexpected route ${route}`);
587
- });
588
- await expect(reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5 })).rejects.toThrow(/not found/);
589
- });
590
-
591
- it("re-throws a genuine error (not a rate limit) from inside a single PR's scan", async () => {
592
- const octokit = octokitWith(async (route) => {
593
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
594
- if (route === "GET /repos/{owner}/{repo}/actions/runs") throw Object.assign(new Error("server exploded"), { status: 500 });
595
- throw new Error(`unexpected route ${route}`);
596
- });
597
- await expect(reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5 })).rejects.toThrow(/server exploded/);
598
- });
599
-
600
- it("re-throws a genuine error (not a rate limit) from the open-PR list", async () => {
601
- const octokit = octokitWith(async (route) => {
602
- if (route === "GET /repos/{owner}/{repo}/pulls") throw Object.assign(new Error("server exploded"), { status: 500 });
603
- throw new Error(`unexpected route ${route}`);
604
- });
605
- await expect(reportCiStatus(octokit, { owner: "cldmv", repo: "r" })).rejects.toThrow(/server exploded/);
606
- });
607
-
608
- it("clamps an out-of-range verbosity into 0-3", async () => {
609
- const octokit = octokitWith(async (route) => {
610
- if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pr("sha1");
611
- if (route === "GET /repos/{owner}/{repo}/actions/runs") return runs([run({ conclusion: "failure" })]);
612
- if (route === "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs") return jobs([job({ id: 555, conclusion: "failure" })]);
613
- throw new Error(`unexpected route ${route}`);
614
- });
615
- // verbosity: 99 clamps to 3 (not silently dropped to 0) — proven by the log fetch actually happening.
616
- const out = await reportCiStatus(octokit, { owner: "cldmv", repo: "r", pull: 5, verbosity: 99 as any }, async () => new Map([[555, "log"]]));
617
- expect(out.failures?.[0]?.logTail).toBe("log");
618
- });
619
-
620
- it("advertises a valid schema", () => {
621
- expect(REPORT_CI_STATUS_SCHEMA.required).toEqual(["owner", "repo"]);
622
- expect(REPORT_CI_STATUS_SCHEMA.additionalProperties).toBe(false);
623
- });
624
- });
package/tsconfig.json DELETED
@@ -1,21 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "lib": ["ES2023"],
7
- "types": ["node"],
8
- "outDir": "dist",
9
- "rootDir": "src",
10
- "allowImportingTsExtensions": true,
11
- "rewriteRelativeImportExtensions": true,
12
- "strict": true,
13
- "noUncheckedIndexedAccess": true,
14
- "noImplicitOverride": true,
15
- "declaration": false,
16
- "sourceMap": true,
17
- "skipLibCheck": true,
18
- "forceConsistentCasingInFileNames": true
19
- },
20
- "include": ["src/**/*.ts"]
21
- }