@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.
Files changed (92) hide show
  1. package/README.md +40 -10
  2. package/config.example.json +17 -1
  3. package/deploy/README.md +3 -2
  4. package/deploy/install.mjs +5 -3
  5. package/dist/index.mjs +4261 -1642
  6. package/dist/index.mjs.map +4 -4
  7. package/package.json +12 -2
  8. package/docs/TOOLS.md +0 -538
  9. package/docs/agent-user-model.md +0 -89
  10. package/docs/changelog/v0/v0.2.0.md +0 -26
  11. package/docs/changelog/v0/v0.2.1.md +0 -23
  12. package/docs/changelog/v0/v0.2.2.md +0 -23
  13. package/docs/changelog/v0/v0.2.3.md +0 -33
  14. package/docs/changelog/v0/v0.2.4.md +0 -25
  15. package/docs/changelog/v0/v0.2.5.md +0 -29
  16. package/docs/changelog/v0/v0.3.0.md +0 -84
  17. package/docs/changelog/v0/v0.3.1.md +0 -30
  18. package/docs/changelog/v0/v0.3.2.md +0 -24
  19. package/docs/changelog/v0/v0.3.3.md +0 -28
  20. package/docs/changelog/v0/v0.4.0.md +0 -58
  21. package/docs/changelog/v0/v0.4.1.md +0 -33
  22. package/docs/changelog/v0/v0.4.2.md +0 -45
  23. package/docs/changelog/v0/v0.5.0.md +0 -39
  24. package/docs/changelog/v0/v0.5.1.md +0 -28
  25. package/docs/changelog/v0/v0.6.0.md +0 -52
  26. package/docs/changelog/v0/v0.6.1.md +0 -48
  27. package/docs/changelog/v0/v0.7.0.md +0 -71
  28. package/docs/changelog/v0/v0.8.0.md +0 -115
  29. package/docs/deploy-architecture.md +0 -84
  30. package/src/appauth.ts +0 -56
  31. package/src/audit.ts +0 -34
  32. package/src/availability.ts +0 -42
  33. package/src/catalog.ts +0 -182
  34. package/src/config.ts +0 -157
  35. package/src/index.ts +0 -107
  36. package/src/paths.ts +0 -60
  37. package/src/router.ts +0 -299
  38. package/src/secrets.ts +0 -167
  39. package/src/server.ts +0 -91
  40. package/src/types.ts +0 -128
  41. package/src/upstream.ts +0 -85
  42. package/src/verbs/ghcr/archive.ts +0 -314
  43. package/src/verbs/ghcr/config.ts +0 -142
  44. package/src/verbs/ghcr/jobs.ts +0 -141
  45. package/src/verbs/ghcr/push.ts +0 -192
  46. package/src/verbs/ghcr/registry.ts +0 -275
  47. package/src/verbs/ghcr/tar.ts +0 -203
  48. package/src/verbs/ghcr_push.ts +0 -264
  49. package/src/verbs/issue_labels.ts +0 -329
  50. package/src/verbs/link_issue.ts +0 -84
  51. package/src/verbs/npm-registry.ts +0 -168
  52. package/src/verbs/npm-tarball.ts +0 -76
  53. package/src/verbs/npm_scaffold.ts +0 -325
  54. package/src/verbs/release-analysis.ts +0 -212
  55. package/src/verbs/repo-tier.ts +0 -90
  56. package/src/verbs/report_ci_status.ts +0 -358
  57. package/src/verbs/report_release_prs.ts +0 -238
  58. package/src/verbs/report_security_alerts.ts +0 -442
  59. package/src/verbs/report_unlabeled.ts +0 -194
  60. package/src/verbs/rest-client.ts +0 -181
  61. package/src/verbs/review_thread.ts +0 -138
  62. package/tests/appauth.test.vitest.mts +0 -76
  63. package/tests/audit.test.vitest.mts +0 -48
  64. package/tests/catalog.test.vitest.mts +0 -234
  65. package/tests/config-merge.test.vitest.mts +0 -325
  66. package/tests/config.test.vitest.mts +0 -388
  67. package/tests/credentials.test.vitest.mts +0 -127
  68. package/tests/ghcr-archive.test.vitest.mts +0 -439
  69. package/tests/ghcr-config.test.vitest.mts +0 -96
  70. package/tests/ghcr-push.test.vitest.mts +0 -526
  71. package/tests/ghcr-registry.test.vitest.mts +0 -206
  72. package/tests/helpers/ghcr-fixtures.mts +0 -373
  73. package/tests/install-args.test.vitest.mts +0 -152
  74. package/tests/issue-labels.test.vitest.mts +0 -362
  75. package/tests/npm-scaffold.test.vitest.mts +0 -479
  76. package/tests/npm-setup.test.vitest.mts +0 -196
  77. package/tests/paths.test.vitest.mts +0 -57
  78. package/tests/prompt.test.vitest.mts +0 -128
  79. package/tests/release-prs.test.vitest.mts +0 -247
  80. package/tests/rest-client.test.vitest.mts +0 -115
  81. package/tests/router.test.vitest.mts +0 -505
  82. package/tests/run-vitest.mjs +0 -51
  83. package/tests/secret-store.test.vitest.mts +0 -167
  84. package/tests/secrets.test.vitest.mts +0 -208
  85. package/tests/security-alerts.test.vitest.mts +0 -338
  86. package/tests/server.test.vitest.mts +0 -200
  87. package/tests/smoke.test.vitest.mts +0 -177
  88. package/tests/system.test.vitest.mts +0 -355
  89. package/tests/upstream.test.vitest.mts +0 -91
  90. package/tests/verb-helpers.test.vitest.mts +0 -377
  91. package/tests/verbs.test.vitest.mts +0 -624
  92. package/tsconfig.json +0 -21
@@ -1,152 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { parseArgs, envSecretKey } from "../deploy/lib/install-args.mjs";
3
-
4
- describe("parseArgs", () => {
5
- it("defaults", () => {
6
- expect(parseArgs([])).toEqual({ positional: [], nonInteractive: false, createAgentUser: false, npm: false, npmScopes: [], npmOrgs: [], cliSecrets: {} });
7
- });
8
-
9
- it("reads every flag", () => {
10
- const o = parseArgs([
11
- "repo",
12
- "-h",
13
- "-y",
14
- "--create-agent-user",
15
- "--agent-user",
16
- "agent",
17
- "--secrets-provider",
18
- "file",
19
- "--secrets-file",
20
- "/x/s.json",
21
- "--npm",
22
- "--npm-scope",
23
- "@cldmv",
24
- "--npm-scope",
25
- "@other",
26
- "--npm-org",
27
- "cldmv",
28
- "--npm-token-days",
29
- "30"
30
- ]);
31
- expect(o).toEqual({
32
- positional: ["repo"],
33
- help: true,
34
- nonInteractive: true,
35
- createAgentUser: true,
36
- agentUser: "agent",
37
- provider: "file",
38
- secretsFile: "/x/s.json",
39
- npm: true,
40
- npmScopes: ["@cldmv", "@other"],
41
- npmOrgs: ["cldmv"],
42
- npmTokenDays: 30,
43
- cliSecrets: {}
44
- });
45
- expect(parseArgs(["--help", "--non-interactive"])).toMatchObject({ help: true, nonInteractive: true });
46
- });
47
-
48
- it("parses every config flag", () => {
49
- const o = parseArgs([
50
- "--config", "/etc/x.json",
51
- "--owner", "acme", "--owner", "beta",
52
- "--installation-id", "acme=42",
53
- "--default-owner", "acme",
54
- "--listen", "unix:/run/x.sock",
55
- "--upstream", "https://example.test/mcp",
56
- "--app-id", "77",
57
- "--app-private-key", "cred:my-key",
58
- "--secrets-path", "/etc/s.json",
59
- "--npm", "--npm-registry", "https://registry.example.test",
60
- "--ghcr-owner", "acme", "--ghcr-username", "Me-1",
61
- "--ghcr-image", "app", "--ghcr-image", "app-*", "--ghcr-image", "team/svc",
62
- "--ghcr-drop-dir", "/srv/x/drop",
63
- "--ghcr-max-archive-bytes", "1048576",
64
- "--no-protect-release-tags"
65
- ]);
66
- expect(o).toMatchObject({
67
- configFile: "/etc/x.json",
68
- owners: ["acme", "beta"],
69
- installationIds: { acme: 42 },
70
- defaultOwner: "acme",
71
- listen: "unix:/run/x.sock",
72
- upstream: "https://example.test/mcp",
73
- appId: 77,
74
- appPrivateKey: "my-key",
75
- secretsPath: "/etc/s.json",
76
- npmRegistry: "https://registry.example.test",
77
- ghcrOwner: "acme",
78
- ghcrUsername: "Me-1",
79
- ghcrImages: ["app", "app-*", "team/svc"],
80
- ghcrDropDir: "/srv/x/drop",
81
- ghcrMaxArchiveBytes: 1048576,
82
- protectReleaseTags: false
83
- });
84
- expect(parseArgs(["--protect-release-tags"]).protectReleaseTags).toBe(true);
85
- expect(parseArgs(["--app-id", "5"]).appPrivateKey).toBeUndefined();
86
- });
87
-
88
- it("leaves the config fields unset when no config flag is given", () => {
89
- const o = parseArgs(["--agent-user", "a"]);
90
- for (const k of ["configFile", "owners", "defaultOwner", "listen", "upstream", "appId", "ghcrOwner", "protectReleaseTags"]) expect(o[k]).toBeUndefined();
91
- });
92
-
93
- it("collects --secret name=token pairs, keeping any = inside the token", () => {
94
- const o = parseArgs(["--secret", "cldmv-read=ghp_abc", "--secret", "npm-token=npm_x=y==", "--secret", "cldmv-read=ghp_new"]);
95
- expect(o.cliSecrets).toEqual({ "cldmv-read": "ghp_new", "npm-token": "npm_x=y==" });
96
- });
97
-
98
- it.each([
99
- [["--secret"], /--secret requires a value/],
100
- [["--secret", "novalue"], /--secret takes <cred-name>=<token>/],
101
- [["--secret", "=tok"], /--secret takes <cred-name>=<token>/],
102
- [["--secret", "name="], /--secret takes <cred-name>=<token>/],
103
- [["--secret", "../x=tok"], /unsafe credential name/],
104
- [["--agent-user", "--secrets-provider", "file"], /--agent-user requires a value/],
105
- [["--secrets-provider", "vault"], /--secrets-provider must be "systemd-creds" or "file"/],
106
- [["--npm-scope", "cldmv"], /--npm-scope must be a lowercase npm scope/],
107
- [["--npm-org", "@cldmv"], /--npm-org must be a lowercase npm org name/],
108
- [["--npm-token-days", "0"], /--npm-token-days must be a whole number of days/],
109
- [["--npm-token-days", "1.5"], /--npm-token-days must be a whole number of days/],
110
- [["--config"], /--config requires a value/],
111
- [["--config", "--owner"], /--config requires a value/],
112
- [["--owner", "Acme"], /--owner must be a lowercase GitHub owner name/],
113
- [["--owner", "-x"], /--owner requires a value/],
114
- [["--installation-id", "acme"], /--installation-id takes <owner>=<numeric id>/],
115
- [["--installation-id", "Acme=1"], /--installation-id takes/],
116
- [["--installation-id", "acme=0"], /--installation-id takes/],
117
- [["--installation-id", "=5"], /--installation-id takes/],
118
- [["--default-owner", "A_B"], /--default-owner must be a lowercase GitHub owner name/],
119
- [["--listen", "/run/x.sock"], /--listen must be a "unix:<absolute path>"/],
120
- [["--upstream", "ftp://x"], /--upstream must be an http\(s\) URL/],
121
- [["--app-id", "abc"], /--app-id must be the numeric GitHub App id/],
122
- [["--app-private-key", "k"], /--app-private-key needs --app-id/],
123
- [["--app-id", "1", "--app-private-key", "../k"], /unsafe credential name/],
124
- [["--secrets-path", "rel.json"], /--secrets-path must be an absolute path/],
125
- [["--npm-registry", "https://r"], /--npm-registry only applies together with --npm/],
126
- [["--npm", "--npm-registry", "http://r"], /--npm-registry must be an https URL/],
127
- [["--ghcr-owner", "Acme"], /--ghcr-owner must be a lowercase GitHub owner name/],
128
- [["--ghcr-owner", "acme"], /--ghcr-owner needs --ghcr-username and at least one --ghcr-image/],
129
- [["--ghcr-owner", "acme", "--ghcr-username", "me"], /--ghcr-owner needs --ghcr-username/],
130
- [["--ghcr-owner", "acme", "--ghcr-image", "app"], /--ghcr-owner needs --ghcr-username/],
131
- [["--ghcr-username", "me"], /need --ghcr-owner/],
132
- [["--ghcr-image", "app"], /need --ghcr-owner/],
133
- [["--ghcr-username", "-bad"], /--ghcr-username requires a value/],
134
- [["--ghcr-username", "bad_name"], /--ghcr-username must be a GitHub login/],
135
- [["--ghcr-image", "Bad/IMG"], /--ghcr-image must be a lowercase image name/],
136
- [["--ghcr-image", "a".repeat(256)], /--ghcr-image must be a lowercase image name/],
137
- [["--ghcr-drop-dir", "drop"], /--ghcr-drop-dir must be an absolute path/],
138
- [["--ghcr-max-archive-bytes", "0"], /--ghcr-max-archive-bytes must be a positive whole number/],
139
- [["--ghcr-max-archive-bytes", "9999999999999999"], /--ghcr-max-archive-bytes must be a positive whole number/],
140
- [["--ghcr-max-archive-bytes", "1.5"], /--ghcr-max-archive-bytes must be a positive whole number/],
141
- [["--bogus"], /unknown flag: --bogus/]
142
- ] as Array<[string[], RegExp]>)("refuses %j", (argv, msg) => {
143
- expect(() => parseArgs(argv)).toThrow(msg);
144
- });
145
- });
146
-
147
- describe("envSecretKey", () => {
148
- it("upper-cases and replaces non-alphanumerics", () => {
149
- expect(envSecretKey("npm-token")).toBe("GH_BROKER_SECRET_NPM_TOKEN");
150
- expect(envSecretKey("cldmv.read")).toBe("GH_BROKER_SECRET_CLDMV_READ");
151
- });
152
- });
@@ -1,362 +0,0 @@
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("reports a set as applied when only building the result fails", async () => {
249
- // The PUT succeeds but answers with a label that has no name, which the result builder cannot lowercase.
250
- const g = fakeGitHub(REPO, ["type: bug"]);
251
- const inner = g.request.getMockImplementation()!;
252
- g.request.mockImplementation(async (route: string, p: any) => (route.startsWith("PUT") ? { data: [{}] } : inner(route, p)));
253
- const err = await issueLabels(g.octokit, { ...base, set: ["type: bug"] }).catch((e) => e);
254
- expect(err.message).toMatch(/failed part-way on cldmv\/gh-broker#117: .*Applied: set \[type: bug\]\. Not applied: nothing\.$/);
255
- });
256
-
257
- it("treats label lists that are not arrays as empty", async () => {
258
- const request = vi.fn(async (route: string) => (route.startsWith("POST /repos/{owner}/{repo}/issues") ? { data: null } : route.startsWith("GET /repos/{owner}/{repo}/issues") ? { data: {} } : { data: { name: "x" } }));
259
- const out = await issueLabels({ request } as any, { ...base, add: "x" });
260
- expect(out.labels).toEqual([]);
261
- });
262
-
263
- it("a failing first read or label lookup writes nothing and is passed through unchanged", async () => {
264
- gh = fakeGitHub(REPO, ["type: bug"], { fail: (route) => (route === "GET /repos/{owner}/{repo}/labels/{name}" ? httpError(500, "Server Error") : undefined) });
265
- await expect(issueLabels(gh.octokit, { ...base, add: "area: auth" })).rejects.toThrow(/^Server Error$/);
266
- expect(writes(gh.request)).toEqual([]);
267
- });
268
- });
269
-
270
- it("skips the DELETE for labels the issue doesn't have (finding 2)", async () => {
271
- const out = await issueLabels(gh.octokit, { ...base, remove: ["area: auth", "nope", "type: bug"] });
272
- expect(gh.request.mock.calls.filter((c) => String(c[0]).startsWith("DELETE")).map((c) => c[1].name)).toEqual(["type: bug"]);
273
- expect(out.removed).toEqual(["type: bug"]);
274
- });
275
-
276
- it("accepts exactly 100 labels on a side", async () => {
277
- const many = Array.from({ length: 100 }, (_, i) => `x${i}`);
278
- const out = await issueLabels(gh.octokit, { ...base, remove: many });
279
- expect(out.removed).toEqual([]);
280
- expect(writes(gh.request)).toEqual([]);
281
- });
282
-
283
- it("does not delete and re-add an add-side label that matches the swap prefix (finding 5)", async () => {
284
- gh = fakeGitHub(REPO, ["status: in progress", "status: not started"]);
285
- const out = await issueLabels(gh.octokit, { ...base, add: "status: in progress", swap: { prefix: "status: ", to: "status: verified" } });
286
- expect(gh.request.mock.calls.filter((c) => String(c[0]).startsWith("DELETE")).map((c) => c[1].name)).toEqual(["status: not started"]);
287
- expect(out.labels).toEqual(["status: in progress", "status: verified"]);
288
- expect(out.removed).toEqual(["status: not started"]);
289
- });
290
-
291
- it("removes using the spelling already on the issue (finding 6)", async () => {
292
- gh = fakeGitHub([...REPO, "Bug"], ["Bug", "status: in progress"], { caseSensitiveDelete: true });
293
- const out = await issueLabels(gh.octokit, { ...base, remove: "bug" });
294
- expect(gh.request.mock.calls.find((c) => String(c[0]).startsWith("DELETE"))?.[1].name).toBe("Bug");
295
- expect(out.labels).toEqual(["status: in progress"]);
296
- expect(out.removed).toEqual(["Bug"]);
297
- });
298
-
299
- it("treats a label removed by someone else since the read as already removed", async () => {
300
- gh = fakeGitHub(REPO, ["type: bug"], { fail: (route) => (route.startsWith("DELETE") ? httpError(404, "Label does not exist") : undefined) });
301
- const out = await issueLabels(gh.octokit, { ...base, remove: "type: bug" });
302
- expect(out.removed).toEqual([]);
303
- });
304
-
305
- it("accepts an uppercase color with #", async () => {
306
- await issueLabels(gh.octokit, { ...base, add: "x", create_missing: true, color: "#ABCDEF" });
307
- expect(gh.request.mock.calls.find((c) => c[0] === "POST /repos/{owner}/{repo}/labels")?.[1].color).toBe("ABCDEF");
308
- });
309
-
310
- describe("input validation", () => {
311
- it.each([
312
- [{ ...base }, /nothing to do/],
313
- [{ ...base, set: ["a"], add: "b" }, /cannot be combined/],
314
- [{ ...base, set: ["a"], swap: { prefix: "s", to: "t" } }, /cannot be combined/],
315
- [{ ...base, add: "a", remove: "A" }, /both added and removed: a/],
316
- [{ ...base, swap: { prefix: "status: ", to: "status: x" }, remove: "status: x" }, /both added and removed/],
317
- [{ ...base, add: "" }, /"add" must be a label name/],
318
- [{ ...base, remove: [42] }, /"remove" must be a label name/],
319
- [{ ...base, swap: { to: "x" } }, /"swap.prefix" is required/],
320
- [{ ...base, swap: { prefix: "s: ", to: [] } }, /"swap.to" is empty/],
321
- [{ ...base, swap: { prefix: "s: " } }, /"swap.to" needs at least one label/],
322
- [{ ...base, issue_number: 0, add: "a" }, /positive integer/],
323
- [{ ...base, issue_number: "abc", add: "a" }, /positive integer/],
324
- [{ ...base, repo: "", add: "a" }, /"repo" must be a non-empty string/],
325
- // Finding 3: an empty set would wipe every label.
326
- [{ ...base, set: [] }, /"set" is empty/],
327
- [{ ...base, add: [] }, /"add" is empty/],
328
- // Finding 4: the handler enforces the schema — unknown keys and wrong types are refused.
329
- [{ ...base, add: "type: bug", remvoe: "priority: high" }, /unknown argument\(s\): remvoe/],
330
- [{ ...base, swap: { prefix: "status: ", to: "status: verified", too: "x" } }, /unknown "swap" key\(s\): too/],
331
- [{ ...base, swap: "status: " }, /"swap" must be an object/],
332
- [{ ...base, issue_number: true, add: "a" }, /"issue_number" must be a positive integer/],
333
- [{ ...base, issue_number: 1.5, add: "a" }, /"issue_number" must be a positive integer/],
334
- [{ ...base, owner: 5, add: "a" }, /"owner" must be a non-empty string/],
335
- [{ ...base, repo: ["gh-broker"], add: "a" }, /"repo" must be a non-empty string/],
336
- [{ ...base, add: "a", create_missing: "yes" }, /"create_missing" must be a boolean/],
337
- // Finding 7: color must be a 6-digit hex value.
338
- [{ ...base, add: "a", create_missing: true, color: "red" }, /"color" must be a 6-digit hex color/],
339
- [{ ...base, add: "a", create_missing: true, color: "#12345" }, /"color" must be a 6-digit hex color/],
340
- [null, /arguments must be an object/],
341
- // Finding 2: list sizes are capped.
342
- [{ ...base, remove: Array.from({ length: 101 }, (_, i) => `l${i}`) }, /"remove" has 101 labels — at most 100/],
343
- [{ ...base, add: Array.from({ length: 101 }, (_, i) => `l${i}`) }, /"add" has 101 labels — at most 100/],
344
- [{ ...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/]
345
- ])("rejects %j", async (input, msg) => {
346
- await expect(issueLabels(gh.octokit, input as any)).rejects.toThrow(msg);
347
- expect(gh.request).not.toHaveBeenCalled();
348
- });
349
-
350
- it("dedupes repeated names case-insensitively", async () => {
351
- await issueLabels(gh.octokit, { ...base, add: ["area: auth", "Area: Auth"] });
352
- const post = gh.request.mock.calls.find((c) => c[0] === "POST /repos/{owner}/{repo}/issues/{issue_number}/labels");
353
- expect(post?.[1].labels).toEqual(["area: auth"]);
354
- });
355
- });
356
-
357
- it("advertises a strict schema", () => {
358
- expect(ISSUE_LABELS_SCHEMA.required).toEqual(["owner", "repo", "issue_number"]);
359
- expect(ISSUE_LABELS_SCHEMA.additionalProperties).toBe(false);
360
- expect(Object.keys(ISSUE_LABELS_SCHEMA.properties)).toEqual(["owner", "repo", "issue_number", "add", "remove", "swap", "set", "create_missing", "color"]);
361
- });
362
- });