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