@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.
- package/.configs/fix-headers.json +3 -0
- package/.configs/vitest.config.mjs +44 -0
- package/.github/dependabot.yml +111 -0
- package/.github/workflows/branch-retention.yml +44 -0
- package/.github/workflows/bundle-size.yml +52 -0
- package/.github/workflows/ci.yml +389 -0
- package/.github/workflows/cla.yml +66 -0
- package/.github/workflows/codeql.yml +87 -0
- package/.github/workflows/dependabot-auto-merge.yml +62 -0
- package/.github/workflows/dependabot-recreate.yml +67 -0
- package/.github/workflows/dependency-review.yml +46 -0
- package/.github/workflows/feature-pr.yml +75 -0
- package/.github/workflows/hotfix-redirector.yml +67 -0
- package/.github/workflows/hotfixes-release.yml +70 -0
- package/.github/workflows/labeler.yml +48 -0
- package/.github/workflows/master-commit-audit.yml +56 -0
- package/.github/workflows/member-auto-merge.yml +75 -0
- package/.github/workflows/next-release.yml +76 -0
- package/.github/workflows/next-reset.yml +50 -0
- package/.github/workflows/pr-notify.yml +44 -0
- package/.github/workflows/pr-title-normalizer.yml +52 -0
- package/.github/workflows/provenance.yml +51 -0
- package/.github/workflows/publish.yml +146 -0
- package/.github/workflows/release-merge.yml +104 -0
- package/.github/workflows/release-notify.yml +41 -0
- package/.github/workflows/scorecard.yml +64 -0
- package/.github/workflows/stale.yml +50 -0
- package/.github/workflows/tag-health.yml +70 -0
- package/.github/workflows/update-major-version-tags.yml +94 -0
- package/.github/workflows/v4-bootstrap.yml +115 -0
- package/.github/workflows/welcome.yml +42 -0
- package/LICENSE +202 -0
- package/README.md +185 -2
- package/config.example.json +281 -0
- package/deploy/README.md +92 -0
- package/deploy/gh-broker.service +57 -0
- package/deploy/install.mjs +344 -0
- package/deploy/install.sh +29 -0
- package/deploy/lib/config-merge.mjs +56 -0
- package/deploy/lib/prompt.mjs +98 -0
- package/deploy/lib/secret-store.mjs +114 -0
- package/deploy/lib/smoke.mjs +137 -0
- package/deploy/lib/system.mjs +222 -0
- package/dist/index.mjs +22970 -0
- package/dist/index.mjs.map +7 -0
- package/docs/TOOLS.md +347 -0
- package/docs/agent-user-model.md +89 -0
- package/docs/changelog/v0/v0.2.0.md +26 -0
- package/docs/changelog/v0/v0.2.1.md +23 -0
- package/docs/changelog/v0/v0.2.2.md +23 -0
- package/docs/changelog/v0/v0.2.3.md +33 -0
- package/docs/changelog/v0/v0.2.4.md +25 -0
- package/docs/changelog/v0/v0.2.5.md +29 -0
- package/docs/changelog/v0/v0.3.0.md +84 -0
- package/docs/changelog/v0/v0.3.1.md +30 -0
- package/docs/changelog/v0/v0.3.2.md +24 -0
- package/docs/changelog/v0/v0.3.3.md +28 -0
- package/docs/changelog/v0/v0.4.0.md +58 -0
- package/docs/changelog/v0/v0.4.1.md +33 -0
- package/docs/changelog/v0/v0.4.2.md +45 -0
- package/docs/changelog/v0/v0.5.0.md +39 -0
- package/docs/changelog/v0/v0.5.1.md +28 -0
- package/docs/changelog/v0/v0.6.0.md +52 -0
- package/docs/changelog/v0/v0.6.1.md +48 -0
- package/docs/changelog/v0/v0.7.0.md +71 -0
- package/docs/deploy-architecture.md +82 -0
- package/package.json +71 -5
- package/src/appauth.ts +56 -0
- package/src/audit.ts +34 -0
- package/src/catalog.ts +165 -0
- package/src/config.ts +130 -0
- package/src/index.ts +107 -0
- package/src/paths.ts +60 -0
- package/src/router.ts +258 -0
- package/src/secrets.ts +137 -0
- package/src/server.ts +91 -0
- package/src/types.ts +104 -0
- package/src/upstream.ts +85 -0
- package/src/verbs/issue_labels.ts +329 -0
- package/src/verbs/link_issue.ts +84 -0
- package/src/verbs/release-analysis.ts +210 -0
- package/src/verbs/repo-tier.ts +90 -0
- package/src/verbs/report_ci_status.ts +358 -0
- package/src/verbs/report_release_prs.ts +238 -0
- package/src/verbs/report_security_alerts.ts +442 -0
- package/src/verbs/report_unlabeled.ts +194 -0
- package/src/verbs/rest-client.ts +181 -0
- package/src/verbs/review_thread.ts +138 -0
- package/tests/appauth.test.vitest.mts +76 -0
- package/tests/audit.test.vitest.mts +48 -0
- package/tests/catalog.test.vitest.mts +180 -0
- package/tests/config-merge.test.vitest.mts +58 -0
- package/tests/config.test.vitest.mts +314 -0
- package/tests/issue-labels.test.vitest.mts +347 -0
- package/tests/paths.test.vitest.mts +52 -0
- package/tests/release-prs.test.vitest.mts +237 -0
- package/tests/rest-client.test.vitest.mts +115 -0
- package/tests/router.test.vitest.mts +399 -0
- package/tests/run-vitest.mjs +51 -0
- package/tests/secret-store.test.vitest.mts +106 -0
- package/tests/secrets.test.vitest.mts +155 -0
- package/tests/security-alerts.test.vitest.mts +328 -0
- package/tests/server.test.vitest.mts +20 -0
- package/tests/upstream.test.vitest.mts +91 -0
- package/tests/verb-helpers.test.vitest.mts +331 -0
- package/tests/verbs.test.vitest.mts +624 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { issueLabels, ISSUE_LABELS_SCHEMA } from "../src/verbs/issue_labels.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A tiny in-memory GitHub: repo labels + one issue's labels, answering the REST routes
|
|
6
|
+
* issue_labels uses the way GitHub does (case-insensitive names, 404 for an unknown
|
|
7
|
+
* label or a label not on the issue, POST appends, PUT replaces, DELETE returns the rest).
|
|
8
|
+
*/
|
|
9
|
+
type FakeOpts = {
|
|
10
|
+
issueExists?: boolean;
|
|
11
|
+
/** Model a DELETE that matches the label name case-sensitively (the worst case). */
|
|
12
|
+
caseSensitiveDelete?: boolean;
|
|
13
|
+
/** Return an error to make that request fail instead of applying it. */
|
|
14
|
+
fail?: (route: string, params: any) => Error | undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const httpError = (status: number, message: string) => Object.assign(new Error(message), { status });
|
|
18
|
+
|
|
19
|
+
function fakeGitHub(repoLabels: string[], issueLabels: string[], opts: FakeOpts = {}) {
|
|
20
|
+
const repo = [...repoLabels];
|
|
21
|
+
let issue = [...issueLabels];
|
|
22
|
+
const notFound = () => Object.assign(new Error("Not Found"), { status: 404 });
|
|
23
|
+
const find = (list: string[], n: string) => list.find((l) => l.toLowerCase() === String(n).toLowerCase());
|
|
24
|
+
const asData = () => issue.map((name) => ({ name }));
|
|
25
|
+
const request = vi.fn(async (route: string, p: any) => {
|
|
26
|
+
if (route.includes("/issues/") && opts.issueExists === false) throw notFound();
|
|
27
|
+
const injected = opts.fail?.(route, p);
|
|
28
|
+
if (injected) throw injected;
|
|
29
|
+
switch (route) {
|
|
30
|
+
case "GET /repos/{owner}/{repo}/issues/{issue_number}/labels":
|
|
31
|
+
return { data: asData() };
|
|
32
|
+
case "GET /repos/{owner}/{repo}/labels/{name}": {
|
|
33
|
+
const hit = find(repo, p.name);
|
|
34
|
+
if (!hit) throw notFound();
|
|
35
|
+
return { data: { name: hit } };
|
|
36
|
+
}
|
|
37
|
+
case "POST /repos/{owner}/{repo}/labels":
|
|
38
|
+
repo.push(p.name);
|
|
39
|
+
return { data: { name: p.name, color: p.color } };
|
|
40
|
+
case "POST /repos/{owner}/{repo}/issues/{issue_number}/labels":
|
|
41
|
+
for (const l of p.labels) if (!find(issue, l)) issue.push(l);
|
|
42
|
+
return { data: asData() };
|
|
43
|
+
case "PUT /repos/{owner}/{repo}/issues/{issue_number}/labels":
|
|
44
|
+
issue = [...p.labels];
|
|
45
|
+
return { data: asData() };
|
|
46
|
+
case "DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}": {
|
|
47
|
+
const hit = opts.caseSensitiveDelete ? issue.find((l) => l === p.name) : find(issue, p.name);
|
|
48
|
+
if (!hit) throw notFound();
|
|
49
|
+
issue = issue.filter((l) => l !== hit);
|
|
50
|
+
return { data: asData() };
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
throw new Error(`unexpected route ${route}`);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return { octokit: { request } as any, request, repo, get issue() { return issue; } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const REPO = ["type: bug", "priority: high", "area: auth", "status: not started", "status: in progress", "status: implemented", "status: verified"];
|
|
60
|
+
const base = { owner: "cldmv", repo: "gh-broker", issue_number: 117 };
|
|
61
|
+
const writes = (request: ReturnType<typeof vi.fn>) => request.mock.calls.map((c) => c[0] as string).filter((r) => !r.startsWith("GET "));
|
|
62
|
+
|
|
63
|
+
describe("issueLabels", () => {
|
|
64
|
+
let gh: ReturnType<typeof fakeGitHub>;
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
gh = fakeGitHub(REPO, ["type: bug", "priority: high", "status: in progress"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("add appends one label and leaves the rest alone", async () => {
|
|
70
|
+
const out = await issueLabels(gh.octokit, { ...base, add: "area: auth" });
|
|
71
|
+
expect(out.labels).toEqual(["type: bug", "priority: high", "status: in progress", "area: auth"]);
|
|
72
|
+
expect(out).toMatchObject({ issue: "cldmv/gh-broker#117", added: ["area: auth"], removed: [], created: [] });
|
|
73
|
+
expect(writes(gh.request)).toEqual(["POST /repos/{owner}/{repo}/issues/{issue_number}/labels"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("add takes several labels; one already on the issue is a no-op", async () => {
|
|
77
|
+
const out = await issueLabels(gh.octokit, { ...base, add: ["area: auth", "priority: high"] });
|
|
78
|
+
expect(out.added).toEqual(["area: auth"]);
|
|
79
|
+
expect(out.labels).toHaveLength(4);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("add writes the repo's own spelling of a case-mismatched name", async () => {
|
|
83
|
+
await issueLabels(gh.octokit, { ...base, add: "AREA: Auth" });
|
|
84
|
+
const post = gh.request.mock.calls.find((c) => c[0] === "POST /repos/{owner}/{repo}/issues/{issue_number}/labels");
|
|
85
|
+
expect(post?.[1].labels).toEqual(["area: auth"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("remove deletes only the named labels; one the issue lacks is a no-op", async () => {
|
|
89
|
+
const out = await issueLabels(gh.octokit, { ...base, remove: ["priority: high", "area: auth"] });
|
|
90
|
+
expect(out.labels).toEqual(["type: bug", "status: in progress"]);
|
|
91
|
+
expect(out.removed).toEqual(["priority: high"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("remove of labels the issue doesn't have still returns the current set", async () => {
|
|
95
|
+
const out = await issueLabels(gh.octokit, { ...base, remove: "area: auth" });
|
|
96
|
+
expect(out.labels).toEqual(["type: bug", "priority: high", "status: in progress"]);
|
|
97
|
+
expect(out.removed).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("remove does not require the label to exist in the repo", async () => {
|
|
101
|
+
const out = await issueLabels(gh.octokit, { ...base, remove: "no such label" });
|
|
102
|
+
expect(out.removed).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("add and remove combine in one call", async () => {
|
|
106
|
+
const out = await issueLabels(gh.octokit, { ...base, add: "area: auth", remove: "priority: high" });
|
|
107
|
+
expect(out.labels).toEqual(["type: bug", "status: in progress", "area: auth"]);
|
|
108
|
+
expect(out).toMatchObject({ added: ["area: auth"], removed: ["priority: high"] });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("swap replaces every label with the prefix and keeps the others", async () => {
|
|
112
|
+
gh = fakeGitHub(REPO, ["type: bug", "status: not started", "priority: high", "status: in progress"]);
|
|
113
|
+
const out = await issueLabels(gh.octokit, { ...base, swap: { prefix: "status: ", to: "status: verified" } });
|
|
114
|
+
expect(out.labels).toEqual(["type: bug", "priority: high", "status: verified"]);
|
|
115
|
+
expect(out.removed).toEqual(["status: not started", "status: in progress"]);
|
|
116
|
+
expect(out.added).toEqual(["status: verified"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("swap leaves a target label that is already present in place instead of re-adding it", async () => {
|
|
120
|
+
gh = fakeGitHub(REPO, ["status: verified", "status: in progress"]);
|
|
121
|
+
const out = await issueLabels(gh.octokit, { ...base, swap: { prefix: "Status: ", to: ["status: verified"] } });
|
|
122
|
+
expect(out.labels).toEqual(["status: verified"]);
|
|
123
|
+
expect(out.removed).toEqual(["status: in progress"]);
|
|
124
|
+
expect(out.added).toEqual([]);
|
|
125
|
+
const deletes = gh.request.mock.calls.filter((c) => String(c[0]).startsWith("DELETE"));
|
|
126
|
+
expect(deletes.map((c) => c[1].name)).toEqual(["status: in progress"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("swap combines with add and remove", async () => {
|
|
130
|
+
const out = await issueLabels(gh.octokit, { ...base, swap: { prefix: "status: ", to: "status: implemented" }, add: "area: auth", remove: "type: bug" });
|
|
131
|
+
expect(out.labels).toEqual(["priority: high", "area: auth", "status: implemented"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("set replaces the whole set and reports the difference", async () => {
|
|
135
|
+
const out = await issueLabels(gh.octokit, { ...base, set: ["type: bug", "status: verified"] });
|
|
136
|
+
expect(out.labels).toEqual(["type: bug", "status: verified"]);
|
|
137
|
+
expect(out.added).toEqual(["status: verified"]);
|
|
138
|
+
expect(out.removed).toEqual(["priority: high", "status: in progress"]);
|
|
139
|
+
expect(writes(gh.request)).toEqual(["PUT /repos/{owner}/{repo}/issues/{issue_number}/labels"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("refuses an unknown add-side label and writes nothing", async () => {
|
|
143
|
+
await expect(issueLabels(gh.octokit, { ...base, add: ["area: auth", "staus: verified"] })).rejects.toThrow(
|
|
144
|
+
/not found in cldmv\/gh-broker: staus: verified — nothing was changed/
|
|
145
|
+
);
|
|
146
|
+
expect(writes(gh.request)).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("refuses an unknown swap target and an unknown set label too", async () => {
|
|
150
|
+
await expect(issueLabels(gh.octokit, { ...base, swap: { prefix: "status: ", to: "status: done" } })).rejects.toThrow(/status: done/);
|
|
151
|
+
await expect(issueLabels(gh.octokit, { ...base, set: ["nope"] })).rejects.toThrow(/nope/);
|
|
152
|
+
expect(writes(gh.request)).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("create_missing creates unknown labels (default color) before applying them", async () => {
|
|
156
|
+
const out = await issueLabels(gh.octokit, { ...base, add: "area: new", create_missing: true });
|
|
157
|
+
expect(out.created).toEqual(["area: new"]);
|
|
158
|
+
expect(out.added).toEqual(["area: new"]);
|
|
159
|
+
const create = gh.request.mock.calls.find((c) => c[0] === "POST /repos/{owner}/{repo}/labels");
|
|
160
|
+
expect(create?.[1]).toMatchObject({ name: "area: new", color: "ededed" });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("create_missing honours a color, with or without #", async () => {
|
|
164
|
+
await issueLabels(gh.octokit, { ...base, add: "x", create_missing: true, color: "#ff0000" });
|
|
165
|
+
const create = gh.request.mock.calls.find((c) => c[0] === "POST /repos/{owner}/{repo}/labels");
|
|
166
|
+
expect(create?.[1].color).toBe("ff0000");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("works on a PR number the same way (same endpoints)", async () => {
|
|
170
|
+
const out = await issueLabels(gh.octokit, { ...base, issue_number: 120, add: "area: auth" });
|
|
171
|
+
expect(out.issue).toBe("cldmv/gh-broker#120");
|
|
172
|
+
expect(gh.request.mock.calls.every((c) => !("issue_number" in c[1]) || c[1].issue_number === 120)).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("a missing issue fails before any label is created", async () => {
|
|
176
|
+
gh = fakeGitHub(REPO, [], { issueExists: false });
|
|
177
|
+
await expect(issueLabels(gh.octokit, { ...base, add: "brand new", create_missing: true })).rejects.toThrow(/Not Found/);
|
|
178
|
+
expect(writes(gh.request)).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("rethrows a non-404 error from a label lookup or a delete", async () => {
|
|
182
|
+
const boom = Object.assign(new Error("Forbidden"), { status: 403 });
|
|
183
|
+
gh.request.mockImplementationOnce(async () => ({ data: [{ name: "type: bug" }] })).mockRejectedValueOnce(boom);
|
|
184
|
+
await expect(issueLabels(gh.octokit, { ...base, add: "area: auth" })).rejects.toThrow(/Forbidden/);
|
|
185
|
+
|
|
186
|
+
gh = fakeGitHub(REPO, ["type: bug"]);
|
|
187
|
+
gh.request.mockImplementationOnce(async () => ({ data: [{ name: "type: bug" }] })).mockRejectedValueOnce(boom);
|
|
188
|
+
await expect(issueLabels(gh.octokit, { ...base, remove: "type: bug" })).rejects.toThrow(/Forbidden/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("partial failures (finding 1)", () => {
|
|
192
|
+
it("a failing add leaves the swap's removals unapplied, so the issue keeps its status label", async () => {
|
|
193
|
+
gh = fakeGitHub(REPO, ["priority: high", "status: in progress"], {
|
|
194
|
+
fail: (route) => (route === "POST /repos/{owner}/{repo}/issues/{issue_number}/labels" ? httpError(403, "API rate limit exceeded") : undefined)
|
|
195
|
+
});
|
|
196
|
+
const err = await issueLabels(gh.octokit, { ...base, swap: { prefix: "status: ", to: "status: verified" } }).catch((e) => e);
|
|
197
|
+
expect(gh.issue).toEqual(["priority: high", "status: in progress"]);
|
|
198
|
+
expect(err.message).toBe(
|
|
199
|
+
"issue_labels: failed part-way on cldmv/gh-broker#117: API rate limit exceeded. Applied: nothing. Not applied: added [status: verified]; removed [status: in progress]."
|
|
200
|
+
);
|
|
201
|
+
expect(err.status).toBe(403);
|
|
202
|
+
expect(err.cause.message).toBe("API rate limit exceeded");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("adds before it removes", async () => {
|
|
206
|
+
gh = fakeGitHub(REPO, ["status: in progress"]);
|
|
207
|
+
await issueLabels(gh.octokit, { ...base, swap: { prefix: "status: ", to: "status: verified" } });
|
|
208
|
+
expect(writes(gh.request)).toEqual([
|
|
209
|
+
"POST /repos/{owner}/{repo}/issues/{issue_number}/labels",
|
|
210
|
+
"DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}"
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("a removal failing mid-list reports which removals went through", async () => {
|
|
215
|
+
gh = fakeGitHub(REPO, ["type: bug", "priority: high", "area: auth"], {
|
|
216
|
+
fail: (route, p) => (route.startsWith("DELETE") && p.name === "priority: high" ? httpError(502, "Bad Gateway") : undefined)
|
|
217
|
+
});
|
|
218
|
+
const err = await issueLabels(gh.octokit, { ...base, add: "status: verified", remove: ["type: bug", "priority: high", "area: auth"] }).catch((e) => e);
|
|
219
|
+
expect(err.message).toBe(
|
|
220
|
+
"issue_labels: failed part-way on cldmv/gh-broker#117: Bad Gateway. Applied: added [status: verified]; removed [type: bug]. Not applied: removed [priority: high, area: auth]."
|
|
221
|
+
);
|
|
222
|
+
expect(gh.issue).toEqual(["priority: high", "area: auth", "status: verified"]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("a create failing part-way reports the label already created and everything still pending", async () => {
|
|
226
|
+
gh = fakeGitHub(REPO, ["status: in progress"], {
|
|
227
|
+
fail: (route, p) => (route === "POST /repos/{owner}/{repo}/labels" && p.name === "area: two" ? httpError(422, "Validation Failed") : undefined)
|
|
228
|
+
});
|
|
229
|
+
const err = await issueLabels(gh.octokit, { ...base, add: ["area: one", "area: two"], remove: "status: in progress", create_missing: true }).catch((e) => e);
|
|
230
|
+
expect(err.message).toBe(
|
|
231
|
+
"issue_labels: failed part-way on cldmv/gh-broker#117: Validation Failed. Applied: created [area: one]. Not applied: created [area: two]; added [area: one, area: two]; removed [status: in progress]."
|
|
232
|
+
);
|
|
233
|
+
expect(gh.repo).toContain("area: one");
|
|
234
|
+
expect(gh.issue).toEqual(["status: in progress"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("a failing set reports the created labels and the set that was not applied", async () => {
|
|
238
|
+
gh = fakeGitHub(REPO, ["type: bug"], {
|
|
239
|
+
fail: (route) => (route.startsWith("PUT") ? httpError(500, "Server Error") : undefined)
|
|
240
|
+
});
|
|
241
|
+
const err = await issueLabels(gh.octokit, { ...base, set: ["type: bug", "area: new"], create_missing: true }).catch((e) => e);
|
|
242
|
+
expect(err.message).toBe(
|
|
243
|
+
"issue_labels: failed part-way on cldmv/gh-broker#117: Server Error. Applied: created [area: new]. Not applied: set [type: bug, area: new]."
|
|
244
|
+
);
|
|
245
|
+
expect(gh.issue).toEqual(["type: bug"]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("a failing first read or label lookup writes nothing and is passed through unchanged", async () => {
|
|
249
|
+
gh = fakeGitHub(REPO, ["type: bug"], { fail: (route) => (route === "GET /repos/{owner}/{repo}/labels/{name}" ? httpError(500, "Server Error") : undefined) });
|
|
250
|
+
await expect(issueLabels(gh.octokit, { ...base, add: "area: auth" })).rejects.toThrow(/^Server Error$/);
|
|
251
|
+
expect(writes(gh.request)).toEqual([]);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("skips the DELETE for labels the issue doesn't have (finding 2)", async () => {
|
|
256
|
+
const out = await issueLabels(gh.octokit, { ...base, remove: ["area: auth", "nope", "type: bug"] });
|
|
257
|
+
expect(gh.request.mock.calls.filter((c) => String(c[0]).startsWith("DELETE")).map((c) => c[1].name)).toEqual(["type: bug"]);
|
|
258
|
+
expect(out.removed).toEqual(["type: bug"]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("accepts exactly 100 labels on a side", async () => {
|
|
262
|
+
const many = Array.from({ length: 100 }, (_, i) => `x${i}`);
|
|
263
|
+
const out = await issueLabels(gh.octokit, { ...base, remove: many });
|
|
264
|
+
expect(out.removed).toEqual([]);
|
|
265
|
+
expect(writes(gh.request)).toEqual([]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("does not delete and re-add an add-side label that matches the swap prefix (finding 5)", async () => {
|
|
269
|
+
gh = fakeGitHub(REPO, ["status: in progress", "status: not started"]);
|
|
270
|
+
const out = await issueLabels(gh.octokit, { ...base, add: "status: in progress", swap: { prefix: "status: ", to: "status: verified" } });
|
|
271
|
+
expect(gh.request.mock.calls.filter((c) => String(c[0]).startsWith("DELETE")).map((c) => c[1].name)).toEqual(["status: not started"]);
|
|
272
|
+
expect(out.labels).toEqual(["status: in progress", "status: verified"]);
|
|
273
|
+
expect(out.removed).toEqual(["status: not started"]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("removes using the spelling already on the issue (finding 6)", async () => {
|
|
277
|
+
gh = fakeGitHub([...REPO, "Bug"], ["Bug", "status: in progress"], { caseSensitiveDelete: true });
|
|
278
|
+
const out = await issueLabels(gh.octokit, { ...base, remove: "bug" });
|
|
279
|
+
expect(gh.request.mock.calls.find((c) => String(c[0]).startsWith("DELETE"))?.[1].name).toBe("Bug");
|
|
280
|
+
expect(out.labels).toEqual(["status: in progress"]);
|
|
281
|
+
expect(out.removed).toEqual(["Bug"]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("treats a label removed by someone else since the read as already removed", async () => {
|
|
285
|
+
gh = fakeGitHub(REPO, ["type: bug"], { fail: (route) => (route.startsWith("DELETE") ? httpError(404, "Label does not exist") : undefined) });
|
|
286
|
+
const out = await issueLabels(gh.octokit, { ...base, remove: "type: bug" });
|
|
287
|
+
expect(out.removed).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("accepts an uppercase color with #", async () => {
|
|
291
|
+
await issueLabels(gh.octokit, { ...base, add: "x", create_missing: true, color: "#ABCDEF" });
|
|
292
|
+
expect(gh.request.mock.calls.find((c) => c[0] === "POST /repos/{owner}/{repo}/labels")?.[1].color).toBe("ABCDEF");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("input validation", () => {
|
|
296
|
+
it.each([
|
|
297
|
+
[{ ...base }, /nothing to do/],
|
|
298
|
+
[{ ...base, set: ["a"], add: "b" }, /cannot be combined/],
|
|
299
|
+
[{ ...base, set: ["a"], swap: { prefix: "s", to: "t" } }, /cannot be combined/],
|
|
300
|
+
[{ ...base, add: "a", remove: "A" }, /both added and removed: a/],
|
|
301
|
+
[{ ...base, swap: { prefix: "status: ", to: "status: x" }, remove: "status: x" }, /both added and removed/],
|
|
302
|
+
[{ ...base, add: "" }, /"add" must be a label name/],
|
|
303
|
+
[{ ...base, remove: [42] }, /"remove" must be a label name/],
|
|
304
|
+
[{ ...base, swap: { to: "x" } }, /"swap.prefix" is required/],
|
|
305
|
+
[{ ...base, swap: { prefix: "s: ", to: [] } }, /"swap.to" is empty/],
|
|
306
|
+
[{ ...base, swap: { prefix: "s: " } }, /"swap.to" needs at least one label/],
|
|
307
|
+
[{ ...base, issue_number: 0, add: "a" }, /positive integer/],
|
|
308
|
+
[{ ...base, issue_number: "abc", add: "a" }, /positive integer/],
|
|
309
|
+
[{ ...base, repo: "", add: "a" }, /"repo" must be a non-empty string/],
|
|
310
|
+
// Finding 3: an empty set would wipe every label.
|
|
311
|
+
[{ ...base, set: [] }, /"set" is empty/],
|
|
312
|
+
[{ ...base, add: [] }, /"add" is empty/],
|
|
313
|
+
// Finding 4: the handler enforces the schema — unknown keys and wrong types are refused.
|
|
314
|
+
[{ ...base, add: "type: bug", remvoe: "priority: high" }, /unknown argument\(s\): remvoe/],
|
|
315
|
+
[{ ...base, swap: { prefix: "status: ", to: "status: verified", too: "x" } }, /unknown "swap" key\(s\): too/],
|
|
316
|
+
[{ ...base, swap: "status: " }, /"swap" must be an object/],
|
|
317
|
+
[{ ...base, issue_number: true, add: "a" }, /"issue_number" must be a positive integer/],
|
|
318
|
+
[{ ...base, issue_number: 1.5, add: "a" }, /"issue_number" must be a positive integer/],
|
|
319
|
+
[{ ...base, owner: 5, add: "a" }, /"owner" must be a non-empty string/],
|
|
320
|
+
[{ ...base, repo: ["gh-broker"], add: "a" }, /"repo" must be a non-empty string/],
|
|
321
|
+
[{ ...base, add: "a", create_missing: "yes" }, /"create_missing" must be a boolean/],
|
|
322
|
+
// Finding 7: color must be a 6-digit hex value.
|
|
323
|
+
[{ ...base, add: "a", create_missing: true, color: "red" }, /"color" must be a 6-digit hex color/],
|
|
324
|
+
[{ ...base, add: "a", create_missing: true, color: "#12345" }, /"color" must be a 6-digit hex color/],
|
|
325
|
+
[null, /arguments must be an object/],
|
|
326
|
+
// Finding 2: list sizes are capped.
|
|
327
|
+
[{ ...base, remove: Array.from({ length: 101 }, (_, i) => `l${i}`) }, /"remove" has 101 labels — at most 100/],
|
|
328
|
+
[{ ...base, add: Array.from({ length: 101 }, (_, i) => `l${i}`) }, /"add" has 101 labels — at most 100/],
|
|
329
|
+
[{ ...base, add: Array.from({ length: 60 }, (_, i) => `a${i}`), swap: { prefix: "s: ", to: Array.from({ length: 41 }, (_, i) => `s: ${i}`) } }, /101 labels to add — at most 100/]
|
|
330
|
+
])("rejects %j", async (input, msg) => {
|
|
331
|
+
await expect(issueLabels(gh.octokit, input as any)).rejects.toThrow(msg);
|
|
332
|
+
expect(gh.request).not.toHaveBeenCalled();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("dedupes repeated names case-insensitively", async () => {
|
|
336
|
+
await issueLabels(gh.octokit, { ...base, add: ["area: auth", "Area: Auth"] });
|
|
337
|
+
const post = gh.request.mock.calls.find((c) => c[0] === "POST /repos/{owner}/{repo}/issues/{issue_number}/labels");
|
|
338
|
+
expect(post?.[1].labels).toEqual(["area: auth"]);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("advertises a strict schema", () => {
|
|
343
|
+
expect(ISSUE_LABELS_SCHEMA.required).toEqual(["owner", "repo", "issue_number"]);
|
|
344
|
+
expect(ISSUE_LABELS_SCHEMA.additionalProperties).toBe(false);
|
|
345
|
+
expect(Object.keys(ISSUE_LABELS_SCHEMA.properties)).toEqual(["owner", "repo", "issue_number", "add", "remove", "swap", "set", "create_missing", "color"]);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { configDir, resolveConfigPath, resolveSecretsPath } from "../src/paths.ts";
|
|
3
|
+
|
|
4
|
+
describe("paths", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
// The imported `env` binding is the live process.env object, so mutating keys
|
|
7
|
+
// (not reassigning) is reflected. Clear the ones under test.
|
|
8
|
+
delete process.env.GH_BROKER_CONFIG;
|
|
9
|
+
delete process.env.GH_BROKER_SECRETS;
|
|
10
|
+
delete process.env.GH_BROKER_CONFIG_DIR;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("configDir defaults to /etc/gh-broker on linux (the test host)", () => {
|
|
14
|
+
expect(configDir()).toBe("/etc/gh-broker");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("configDir honors the GH_BROKER_CONFIG_DIR override", () => {
|
|
18
|
+
process.env.GH_BROKER_CONFIG_DIR = "/opt/cfg";
|
|
19
|
+
expect(configDir()).toBe("/opt/cfg");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("resolveConfigPath precedence: explicit > env > default", () => {
|
|
23
|
+
expect(resolveConfigPath("/explicit.json")).toBe("/explicit.json");
|
|
24
|
+
process.env.GH_BROKER_CONFIG = "/env.json";
|
|
25
|
+
expect(resolveConfigPath()).toBe("/env.json");
|
|
26
|
+
delete process.env.GH_BROKER_CONFIG;
|
|
27
|
+
expect(resolveConfigPath()).toBe("/etc/gh-broker/config.json");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("resolveSecretsPath precedence: explicit > env > default", () => {
|
|
31
|
+
expect(resolveSecretsPath("/explicit-s.json")).toBe("/explicit-s.json");
|
|
32
|
+
process.env.GH_BROKER_SECRETS = "/env-s.json";
|
|
33
|
+
expect(resolveSecretsPath()).toBe("/env-s.json");
|
|
34
|
+
delete process.env.GH_BROKER_SECRETS;
|
|
35
|
+
expect(resolveSecretsPath()).toBe("/etc/gh-broker/secrets.json");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("configDir maps darwin and win32 platforms", async () => {
|
|
39
|
+
vi.resetModules();
|
|
40
|
+
vi.doMock("node:process", () => ({ platform: "darwin", env: {} }));
|
|
41
|
+
const mac = await import("../src/paths.ts");
|
|
42
|
+
expect(mac.configDir()).toBe("/Library/Application Support/gh-broker");
|
|
43
|
+
|
|
44
|
+
vi.resetModules();
|
|
45
|
+
vi.doMock("node:process", () => ({ platform: "win32", env: { ProgramData: "C:/PD" } }));
|
|
46
|
+
const win = await import("../src/paths.ts");
|
|
47
|
+
expect(win.configDir()).toContain("gh-broker");
|
|
48
|
+
|
|
49
|
+
vi.doUnmock("node:process");
|
|
50
|
+
vi.resetModules();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { reportReleasePrs, parseReleaseVersion, changelogPaths, REPORT_RELEASE_PRS_SCHEMA } from "../src/verbs/report_release_prs.ts";
|
|
3
|
+
|
|
4
|
+
type Handler = (route: string, params: any) => Promise<{ data: unknown; headers?: Record<string, string> }>;
|
|
5
|
+
|
|
6
|
+
function octokitWith(handler: Handler): any {
|
|
7
|
+
return { request: vi.fn(handler) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const status = (code: number, message = "x") => Object.assign(new Error(message), { status: code });
|
|
11
|
+
|
|
12
|
+
const hit = (repo: string, number: number, title: string, over: Record<string, unknown> = {}) => ({
|
|
13
|
+
number,
|
|
14
|
+
title,
|
|
15
|
+
html_url: `https://github.com/CLDMV/${repo}/pull/${number}`,
|
|
16
|
+
repository_url: `https://api.github.com/repos/CLDMV/${repo}`,
|
|
17
|
+
...over
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const search = (items: unknown[], over: Record<string, unknown> = {}) => ({ data: { total_count: items.length, incomplete_results: false, items, ...over }, headers: {} });
|
|
21
|
+
|
|
22
|
+
const pull = (head = "next", base = "master", draft = false) => ({ data: { head: { ref: head }, base: { ref: base }, draft }, headers: {} });
|
|
23
|
+
|
|
24
|
+
describe("parseReleaseVersion", () => {
|
|
25
|
+
it("reads the version out of a release title, with or without the v", () => {
|
|
26
|
+
expect(parseReleaseVersion("release: v0.4.2 - bump the patch group")).toBe("0.4.2");
|
|
27
|
+
expect(parseReleaseVersion("release: 1.0.0")).toBe("1.0.0");
|
|
28
|
+
expect(parseReleaseVersion("Release: v2.10.13-rc.1 - x")).toBe("2.10.13-rc.1");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns null when the title carries no version", () => {
|
|
32
|
+
expect(parseReleaseVersion("chore: tidy up")).toBeNull();
|
|
33
|
+
expect(parseReleaseVersion("release: soon")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("changelogPaths", () => {
|
|
38
|
+
it("lists both changelog/ and changelogs/ under the major folder", () => {
|
|
39
|
+
expect(changelogPaths("0.4.2")).toEqual(["docs/changelog/v0/v0.4.2.md", "docs/changelogs/v0/v0.4.2.md"]);
|
|
40
|
+
expect(changelogPaths("12.3.4")).toEqual(["docs/changelog/v12/v12.3.4.md", "docs/changelogs/v12/v12.3.4.md"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("reportReleasePrs", () => {
|
|
45
|
+
it("searches the org once for open release-labelled PRs", async () => {
|
|
46
|
+
const octokit = octokitWith(async () => search([]));
|
|
47
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
48
|
+
expect(octokit.request).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(octokit.request).toHaveBeenCalledWith("GET /search/issues", { q: "org:cldmv is:pr is:open label:release", per_page: 100 });
|
|
50
|
+
expect(out).toEqual({ scanned: { releasePrs: 0 }, summary: { ready: 0, needsChangelog: 0, unknownVersion: 0 }, releasePrs: [] });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("scopes the search to one repo when repo is given", async () => {
|
|
54
|
+
const octokit = octokitWith(async () => search([]));
|
|
55
|
+
await reportReleasePrs(octokit, { owner: "cldmv", repo: "gh-broker" });
|
|
56
|
+
expect(octokit.request.mock.calls[0]![1].q).toBe("repo:cldmv/gh-broker is:pr is:open label:release");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("marks a PR ready when the changelog exists at the PR head commit, and stops at the first path found", async () => {
|
|
60
|
+
const octokit = octokitWith(async (route, params) => {
|
|
61
|
+
if (route === "GET /search/issues") return search([hit("gh-broker", 88, "release: v0.4.2 - stuff")]);
|
|
62
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull("next", "master");
|
|
63
|
+
if (route === "GET /repos/{owner}/{repo}/contents/{path}") {
|
|
64
|
+
expect(params).toMatchObject({ owner: "CLDMV", repo: "gh-broker", ref: "next", path: "docs/changelog/v0/v0.4.2.md" });
|
|
65
|
+
return { data: { type: "file" }, headers: {} };
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`unexpected route ${route}`);
|
|
68
|
+
});
|
|
69
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
70
|
+
expect(out.releasePrs).toEqual([
|
|
71
|
+
{
|
|
72
|
+
repo: "CLDMV/gh-broker",
|
|
73
|
+
pull: 88,
|
|
74
|
+
url: "https://github.com/CLDMV/gh-broker/pull/88",
|
|
75
|
+
title: "release: v0.4.2 - stuff",
|
|
76
|
+
version: "0.4.2",
|
|
77
|
+
head: "next",
|
|
78
|
+
base: "master",
|
|
79
|
+
draft: false,
|
|
80
|
+
status: "ready",
|
|
81
|
+
changelogPath: "docs/changelog/v0/v0.4.2.md"
|
|
82
|
+
}
|
|
83
|
+
]);
|
|
84
|
+
expect(out.summary).toEqual({ ready: 1, needsChangelog: 0, unknownVersion: 0 });
|
|
85
|
+
expect(octokit.request).toHaveBeenCalledTimes(3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("falls through to docs/changelogs/ when docs/changelog/ 404s", async () => {
|
|
89
|
+
const octokit = octokitWith(async (route, params) => {
|
|
90
|
+
if (route === "GET /search/issues") return search([hit("r", 1, "release: v1.2.3")]);
|
|
91
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
|
|
92
|
+
if (params.path === "docs/changelog/v1/v1.2.3.md") throw status(404, "Not Found");
|
|
93
|
+
return { data: {}, headers: {} };
|
|
94
|
+
});
|
|
95
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
96
|
+
expect(out.releasePrs[0]).toMatchObject({ status: "ready", changelogPath: "docs/changelogs/v1/v1.2.3.md" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("marks a PR needs-changelog and lists the paths it checked when neither exists", async () => {
|
|
100
|
+
const octokit = octokitWith(async (route) => {
|
|
101
|
+
if (route === "GET /search/issues") return search([hit("r", 4, "release: v0.1.0 - x")]);
|
|
102
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
|
|
103
|
+
throw status(404, "Not Found");
|
|
104
|
+
});
|
|
105
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
106
|
+
expect(out.releasePrs[0]).toMatchObject({ status: "needs-changelog", version: "0.1.0", checkedPaths: ["docs/changelog/v0/v0.1.0.md", "docs/changelogs/v0/v0.1.0.md"] });
|
|
107
|
+
expect(out.releasePrs[0]).not.toHaveProperty("changelogPath");
|
|
108
|
+
expect(out.summary).toEqual({ ready: 0, needsChangelog: 1, unknownVersion: 0 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("marks a PR unknown-version, without looking for a changelog, when the title has no version", async () => {
|
|
112
|
+
const octokit = octokitWith(async (route) => {
|
|
113
|
+
if (route === "GET /search/issues") return search([hit("r", 6, "Release candidate")]);
|
|
114
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull("hotfixes", "master");
|
|
115
|
+
throw new Error(`unexpected route ${route}`);
|
|
116
|
+
});
|
|
117
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
118
|
+
expect(out.releasePrs[0]).toMatchObject({ status: "unknown-version", version: null, head: "hotfixes" });
|
|
119
|
+
expect(octokit.request).toHaveBeenCalledTimes(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("covers hotfixes release PRs across several repos and counts each status", async () => {
|
|
123
|
+
const octokit = octokitWith(async (route, params) => {
|
|
124
|
+
if (route === "GET /search/issues") {
|
|
125
|
+
return search([hit("a", 1, "release: v1.0.1 - x"), hit("b", 2, "release: v2.0.0 - y"), hit("c", 3, "no version here")]);
|
|
126
|
+
}
|
|
127
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull(params.repo === "a" ? "hotfixes" : "next");
|
|
128
|
+
if (params.repo === "a") return { data: {}, headers: {} };
|
|
129
|
+
throw status(404);
|
|
130
|
+
});
|
|
131
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
132
|
+
expect(out.summary).toEqual({ ready: 1, needsChangelog: 1, unknownVersion: 1 });
|
|
133
|
+
expect(out.releasePrs.map((p) => `${p.repo}:${p.head}:${p.status}`)).toEqual(["CLDMV/a:hotfixes:ready", "CLDMV/b:next:needs-changelog", "CLDMV/c:next:unknown-version"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("onlyMissing drops ready PRs from the list but keeps the summary counts", async () => {
|
|
137
|
+
const octokit = octokitWith(async (route, params) => {
|
|
138
|
+
if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.1"), hit("b", 2, "release: v2.0.0")]);
|
|
139
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
|
|
140
|
+
if (params.repo === "a") return { data: {}, headers: {} };
|
|
141
|
+
throw status(404);
|
|
142
|
+
});
|
|
143
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv", onlyMissing: true });
|
|
144
|
+
expect(out.releasePrs.map((p) => p.repo)).toEqual(["CLDMV/b"]);
|
|
145
|
+
expect(out.summary).toEqual({ ready: 1, needsChangelog: 1, unknownVersion: 0 });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("reports draft from the pull, falling back to the search hit", async () => {
|
|
149
|
+
const octokit = octokitWith(async (route) => {
|
|
150
|
+
if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0", { draft: true })]);
|
|
151
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return { data: { head: { ref: "next" }, base: { ref: "master" } }, headers: {} };
|
|
152
|
+
return { data: {}, headers: {} };
|
|
153
|
+
});
|
|
154
|
+
expect((await reportReleasePrs(octokit, { owner: "cldmv" })).releasePrs[0]!.draft).toBe(true);
|
|
155
|
+
const plain = octokitWith(async (route) => {
|
|
156
|
+
if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")]);
|
|
157
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return { data: { head: { ref: "next" }, base: { ref: "master" } }, headers: {} };
|
|
158
|
+
return { data: {}, headers: {} };
|
|
159
|
+
});
|
|
160
|
+
expect((await reportReleasePrs(plain, { owner: "cldmv" })).releasePrs[0]!.draft).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("flags max-results when the search has more hits than one page returned", async () => {
|
|
164
|
+
const octokit = octokitWith(async (route) => {
|
|
165
|
+
if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")], { total_count: 150 });
|
|
166
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
|
|
167
|
+
return { data: {}, headers: {} };
|
|
168
|
+
});
|
|
169
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
170
|
+
expect(out.truncated).toBe(true);
|
|
171
|
+
expect(out.truncatedReason).toBe("max-results");
|
|
172
|
+
expect(out.releasePrs).toHaveLength(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("flags incomplete-search when GitHub says the search timed out", async () => {
|
|
176
|
+
const octokit = octokitWith(async () => search([], { incomplete_results: true }));
|
|
177
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
178
|
+
expect(out.truncatedReason).toBe("incomplete-search");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("returns what it has, flagged max-api-calls, when the call budget runs out", async () => {
|
|
182
|
+
const octokit = octokitWith(async (route) => {
|
|
183
|
+
if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0"), hit("b", 2, "release: v2.0.0")]);
|
|
184
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
|
|
185
|
+
return { data: {}, headers: {} };
|
|
186
|
+
});
|
|
187
|
+
// search (1) + pull (2) + contents (3) for the first PR, then the second PR's pull lookup is over budget.
|
|
188
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv", maxApiCalls: 3 });
|
|
189
|
+
expect(out.truncated).toBe(true);
|
|
190
|
+
expect(out.truncatedReason).toBe("max-api-calls");
|
|
191
|
+
expect(out.releasePrs).toHaveLength(1);
|
|
192
|
+
expect(out.scanned).toEqual({ releasePrs: 2 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns partial results with retryAfterSeconds when rate limited", async () => {
|
|
196
|
+
const octokit = octokitWith(async (route) => {
|
|
197
|
+
if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")]);
|
|
198
|
+
throw Object.assign(new Error("Forbidden"), { status: 403, response: { headers: { "retry-after": "20" } } });
|
|
199
|
+
});
|
|
200
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
201
|
+
expect(out.truncatedReason).toBe("rate-limited");
|
|
202
|
+
expect(out.retryAfterSeconds).toBe(20);
|
|
203
|
+
expect(out.releasePrs).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("omits retryAfterSeconds when GitHub sent none", async () => {
|
|
207
|
+
const octokit = octokitWith(async () => { throw status(429, "Too Many Requests"); });
|
|
208
|
+
const out = await reportReleasePrs(octokit, { owner: "cldmv" });
|
|
209
|
+
expect(out.truncatedReason).toBe("rate-limited");
|
|
210
|
+
expect(out).not.toHaveProperty("retryAfterSeconds");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("propagates a non-404 error from the changelog lookup instead of calling it missing", async () => {
|
|
214
|
+
const octokit = octokitWith(async (route) => {
|
|
215
|
+
if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")]);
|
|
216
|
+
if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
|
|
217
|
+
throw status(500, "server exploded");
|
|
218
|
+
});
|
|
219
|
+
await expect(reportReleasePrs(octokit, { owner: "cldmv" })).rejects.toThrow("server exploded");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("propagates a plain permission 403 on the search", async () => {
|
|
223
|
+
const octokit = octokitWith(async () => { throw status(403, "Resource not accessible by personal access token"); });
|
|
224
|
+
await expect(reportReleasePrs(octokit, { owner: "cldmv" })).rejects.toThrow("Resource not accessible");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("clamps maxApiCalls to the hard cap", async () => {
|
|
228
|
+
const octokit = octokitWith(async () => search([]));
|
|
229
|
+
await reportReleasePrs(octokit, { owner: "cldmv", maxApiCalls: 999999 });
|
|
230
|
+
expect(octokit.request).toHaveBeenCalledTimes(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("advertises a valid schema", () => {
|
|
234
|
+
expect(REPORT_RELEASE_PRS_SCHEMA.required).toEqual(["owner"]);
|
|
235
|
+
expect(REPORT_RELEASE_PRS_SCHEMA.additionalProperties).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
});
|