@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.
- package/README.md +40 -10
- package/config.example.json +17 -1
- package/deploy/README.md +3 -2
- package/deploy/install.mjs +5 -3
- package/dist/index.mjs +4261 -1642
- package/dist/index.mjs.map +4 -4
- package/package.json +12 -2
- package/docs/TOOLS.md +0 -538
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- 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
|
-
}
|