@cldmv/gh-broker 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. package/tsconfig.json +0 -21
@@ -1,115 +0,0 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { CallBudget, CallBudgetExhausted, RateLimited, listOwnerRepos, paginateAfter, req } from "../src/verbs/rest-client.ts";
3
-
4
- function octokitWith(handler: (route: string, params: any) => Promise<{ data: unknown; headers?: Record<string, string> }>): any {
5
- return { request: vi.fn(handler) };
6
- }
7
-
8
- describe("403 classification", () => {
9
- const call = (err: unknown) => req(octokitWith(async () => { throw err; }), new CallBudget(5), "GET /x", {});
10
-
11
- it("treats a 403 with Retry-After as a rate limit and carries the delay", async () => {
12
- const err = Object.assign(new Error("Forbidden"), { status: 403, response: { headers: { "retry-after": "42" } } });
13
- await expect(call(err)).rejects.toMatchObject({ retryAfterSeconds: 42 });
14
- await expect(call(err)).rejects.toBeInstanceOf(RateLimited);
15
- });
16
-
17
- it("treats a 403 with an exhausted x-ratelimit-remaining as a rate limit", async () => {
18
- const err = Object.assign(new Error("Forbidden"), { status: 403, response: { headers: { "x-ratelimit-remaining": "0" } } });
19
- await expect(call(err)).rejects.toBeInstanceOf(RateLimited);
20
- });
21
-
22
- it("treats a 403 whose message mentions a rate limit or abuse detection as a rate limit", async () => {
23
- await expect(call(Object.assign(new Error("API rate limit exceeded"), { status: 403 }))).rejects.toBeInstanceOf(RateLimited);
24
- await expect(call(Object.assign(new Error("You have exceeded a secondary rate limit"), { status: 403 }))).rejects.toBeInstanceOf(RateLimited);
25
- await expect(call(Object.assign(new Error("abuse detection mechanism"), { status: 403 }))).rejects.toBeInstanceOf(RateLimited);
26
- });
27
-
28
- it("always treats a 429 as a rate limit", async () => {
29
- await expect(call(Object.assign(new Error("Too Many Requests"), { status: 429 }))).rejects.toBeInstanceOf(RateLimited);
30
- });
31
-
32
- it("rethrows a plain permission 403 with its status intact instead of calling it a rate limit", async () => {
33
- const err = Object.assign(new Error("Code Security must be enabled for this repository to use code scanning."), { status: 403 });
34
- const rejected = await call(err).catch((e) => e);
35
- expect(rejected).not.toBeInstanceOf(RateLimited);
36
- expect(rejected).toBe(err);
37
- expect(rejected.status).toBe(403);
38
- });
39
-
40
- it("still rethrows other statuses untouched", async () => {
41
- const err = Object.assign(new Error("Not Found"), { status: 404 });
42
- await expect(call(err)).rejects.toBe(err);
43
- });
44
- });
45
-
46
- describe("paginateAfter (Link-header cursor pagination)", () => {
47
- const link = (cursor: string) => `<https://api.github.com/x?per_page=100&after=${cursor}>; rel="next", <https://api.github.com/x?per_page=100>; rel="first"`;
48
-
49
- it("follows the rel=next cursor across pages and stops when there is no next link", async () => {
50
- const octokit = octokitWith(async (_route, params) => {
51
- if (!params.after) return { data: [{ n: 1 }, { n: 2 }], headers: { link: link("CUR1") } };
52
- if (params.after === "CUR1") return { data: [{ n: 3 }], headers: {} };
53
- throw new Error(`unexpected cursor ${params.after}`);
54
- });
55
- const out = await paginateAfter<{ n: number }>(octokit, new CallBudget(10), "GET /x", { a: 1 }, 100);
56
- expect(out.map((o) => o.n)).toEqual([1, 2, 3]);
57
- expect(octokit.request).toHaveBeenCalledTimes(2);
58
- expect(octokit.request.mock.calls[0]![1]).toMatchObject({ a: 1, per_page: 100 });
59
- expect(octokit.request.mock.calls[0]![1]).not.toHaveProperty("after");
60
- });
61
-
62
- it("URL-decodes the cursor before sending it back", async () => {
63
- const octokit = octokitWith(async (_route, params) => {
64
- if (!params.after) return { data: [{ n: 1 }], headers: { link: link("a%3Db%2Fc") } };
65
- expect(params.after).toBe("a=b/c");
66
- return { data: [], headers: {} };
67
- });
68
- await paginateAfter(octokit, new CallBudget(10), "GET /x", {}, 100);
69
- expect(octokit.request).toHaveBeenCalledTimes(2);
70
- });
71
-
72
- it("stops at the limit even when more pages exist, trimming to the limit", async () => {
73
- const octokit = octokitWith(async () => ({ data: [{ n: 1 }, { n: 2 }, { n: 3 }], headers: { link: link("MORE") } }));
74
- const out = await paginateAfter(octokit, new CallBudget(10), "GET /x", {}, 2);
75
- expect(out).toHaveLength(2);
76
- expect(octokit.request).toHaveBeenCalledTimes(1);
77
- });
78
-
79
- it("stops on an empty page and on a non-array body", async () => {
80
- expect(await paginateAfter(octokitWith(async () => ({ data: [], headers: { link: link("X") } })), new CallBudget(5), "GET /x", {}, 10)).toEqual([]);
81
- expect(await paginateAfter(octokitWith(async () => ({ data: { message: "nope" }, headers: {} })), new CallBudget(5), "GET /x", {}, 10)).toEqual([]);
82
- });
83
-
84
- it("charges the call budget per page and throws once it is spent", async () => {
85
- const octokit = octokitWith(async () => ({ data: [{ n: 1 }], headers: { link: link("MORE") } }));
86
- await expect(paginateAfter(octokit, new CallBudget(2), "GET /x", {}, 100)).rejects.toBeInstanceOf(CallBudgetExhausted);
87
- });
88
- });
89
-
90
- describe("listOwnerRepos", () => {
91
- it("returns non-archived repo names from the org endpoint", async () => {
92
- const octokit = octokitWith(async () => ({ data: [{ name: "a" }, { name: "b", archived: true }, { name: "c" }], headers: {} }));
93
- expect(await listOwnerRepos(octokit, new CallBudget(5), "cldmv", 50)).toEqual(["a", "c"]);
94
- });
95
-
96
- it("falls back to the user endpoint when the org endpoint 404s", async () => {
97
- const octokit = octokitWith(async (route) => {
98
- if (route === "GET /orgs/{owner}/repos") throw Object.assign(new Error("Not Found"), { status: 404 });
99
- return { data: [{ name: "solo" }], headers: {} };
100
- });
101
- expect(await listOwnerRepos(octokit, new CallBudget(5), "someone", 50)).toEqual(["solo"]);
102
- });
103
-
104
- it("does not fall back on a rate limit or budget exhaustion", async () => {
105
- const limited = octokitWith(async () => { throw Object.assign(new Error("x"), { status: 429 }); });
106
- await expect(listOwnerRepos(limited, new CallBudget(5), "cldmv", 50)).rejects.toBeInstanceOf(RateLimited);
107
- expect(limited.request).toHaveBeenCalledTimes(1);
108
- await expect(listOwnerRepos(octokitWith(async () => ({ data: [], headers: {} })), new CallBudget(0), "cldmv", 50)).rejects.toBeInstanceOf(CallBudgetExhausted);
109
- });
110
-
111
- it("propagates a non-404 error", async () => {
112
- const octokit = octokitWith(async () => { throw Object.assign(new Error("boom"), { status: 500 }); });
113
- await expect(listOwnerRepos(octokit, new CallBudget(5), "cldmv", 50)).rejects.toThrow("boom");
114
- });
115
- });
@@ -1,505 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
-
3
- // Octokit is `new`-ed inside dispatchCustom for the review-thread verbs, so mock it
4
- // to hand back a controllable graphql fn. link_issue instead uses the injected
5
- // appAuth's octokit (also wired to graphqlMock below).
6
- const { graphqlMock, requestMock } = vi.hoisted(() => ({ graphqlMock: vi.fn(), requestMock: vi.fn() }));
7
- vi.mock("@octokit/core", () => ({
8
- // Constructable mock (arrow fns can't be `new`-ed) — every instance shares graphqlMock/requestMock.
9
- Octokit: vi.fn(function (this: any) {
10
- this.graphql = graphqlMock;
11
- this.request = requestMock;
12
- })
13
- }));
14
-
15
- import { Octokit } from "@octokit/core";
16
- import { Router, extractOwner } from "../src/router.ts";
17
-
18
- function makeConfig(overrides: Record<string, unknown> = {}): any {
19
- return {
20
- listen: "unix:/x.sock",
21
- upstream: "https://api.githubcopilot.com/mcp/",
22
- tools: [
23
- { name: "get_me", mode: "read" },
24
- { name: "create_pull_request", mode: "write" },
25
- { name: "link_issue", mode: "write", custom: true },
26
- { name: "resolve_review_thread", mode: "write", custom: true },
27
- { name: "reply_review_thread", mode: "write", custom: true },
28
- { name: "issue_labels", mode: "write", custom: true },
29
- { name: "report_unlabeled", mode: "read", custom: true },
30
- { name: "report_security_alerts", mode: "read", custom: true },
31
- { name: "report_release_prs", mode: "read", custom: true },
32
- { name: "report_ci_status", mode: "read", custom: true },
33
- { name: "pull_request_read", mode: "read", augment: true }
34
- ],
35
- owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } },
36
- ...overrides
37
- };
38
- }
39
-
40
- describe("extractOwner", () => {
41
- it("reads args.owner (lowercased)", () => {
42
- expect(extractOwner({ owner: "CLDMV" })).toBe("cldmv");
43
- });
44
- it("falls back to args.org for org-scoped tools", () => {
45
- expect(extractOwner({ org: "SomeOrg" })).toBe("someorg");
46
- });
47
- it("extracts repo:owner/name out of a search query", () => {
48
- expect(extractOwner({ query: "is:pr repo:CLDMV/gh-broker author:x" })).toBe("cldmv");
49
- });
50
- it("extracts org: out of a search query", () => {
51
- expect(extractOwner({ query: "org:Acme is:issue" })).toBe("acme");
52
- });
53
- it("extracts user: out of a search query", () => {
54
- expect(extractOwner({ query: "user:Someone foo" })).toBe("someone");
55
- });
56
- it("reads the owner from a repository owner/name string", () => {
57
- expect(extractOwner({ repository: "CLDMV/thing" })).toBe("cldmv");
58
- });
59
- it("returns undefined when no owner is derivable", () => {
60
- expect(extractOwner({ foo: "bar" })).toBeUndefined();
61
- expect(extractOwner({ repository: "no-slash" })).toBeUndefined();
62
- expect(extractOwner({ query: "just some words" })).toBeUndefined();
63
- });
64
- });
65
-
66
- describe("Router.call", () => {
67
- let secrets: { resolve: ReturnType<typeof vi.fn> };
68
- let upstream: { forward: ReturnType<typeof vi.fn> };
69
-
70
- beforeEach(() => {
71
- vi.clearAllMocks();
72
- vi.spyOn(console, "log").mockImplementation(() => {}); // silence audit output
73
- secrets = { resolve: vi.fn((ref: string) => `token:${ref}`), has: vi.fn(() => true) };
74
- upstream = { forward: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }) };
75
- });
76
-
77
- afterEach(() => {
78
- vi.restoreAllMocks();
79
- });
80
-
81
- it("denies a tool that is not in the allowlist", async () => {
82
- const r = new Router(makeConfig(), secrets as any, upstream as any);
83
- await expect(r.call("rm_rf", {})).rejects.toThrow(/tool "rm_rf" is not allowed/);
84
- expect(upstream.forward).not.toHaveBeenCalled();
85
- });
86
-
87
- it("advertisedTools returns the config tools", () => {
88
- const cfg = makeConfig();
89
- const r = new Router(cfg, secrets as any, upstream as any);
90
- expect(r.advertisedTools()).toEqual(cfg.tools);
91
- });
92
-
93
- it("advertisedTools leaves out a tool whose optional integration is not set up", () => {
94
- const cfg = makeConfig({ tools: [{ name: "get_me", mode: "read" }, { name: "npm_scaffold", mode: "write", custom: true }] });
95
- expect(new Router(cfg, secrets as any, upstream as any).advertisedTools().map((t) => t.name)).toEqual(["get_me"]);
96
- });
97
-
98
- it("forwards a read call with the resolved read PAT", async () => {
99
- const r = new Router(makeConfig(), secrets as any, upstream as any);
100
- const out = await r.call("get_me", { owner: "CLDMV" });
101
- expect(secrets.resolve).toHaveBeenCalledWith("cred:cldmv-read");
102
- expect(upstream.forward).toHaveBeenCalledWith("get_me", { owner: "CLDMV" }, "token:cred:cldmv-read");
103
- expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
104
- });
105
-
106
- it("uses the write PAT for a write-mode tool", async () => {
107
- const r = new Router(makeConfig(), secrets as any, upstream as any);
108
- await r.call("create_pull_request", { owner: "cldmv", repo: "gh-broker" });
109
- expect(secrets.resolve).toHaveBeenCalledWith("cred:cldmv-write");
110
- });
111
-
112
- it("falls back an owner-less READ to defaultOwner's read PAT", async () => {
113
- const r = new Router(makeConfig({ defaultOwner: "cldmv" }), secrets as any, upstream as any);
114
- await r.call("get_me", {});
115
- expect(secrets.resolve).toHaveBeenCalledWith("cred:cldmv-read");
116
- expect(upstream.forward).toHaveBeenCalledWith("get_me", {}, "token:cred:cldmv-read");
117
- });
118
-
119
- it("does NOT fall back an owner-less WRITE, even with defaultOwner set", async () => {
120
- const r = new Router(makeConfig({ defaultOwner: "cldmv" }), secrets as any, upstream as any);
121
- await expect(r.call("create_pull_request", {})).rejects.toThrow(/could not determine repo owner/);
122
- });
123
-
124
- it("errors on an owner-less read when there is no defaultOwner", async () => {
125
- const r = new Router(makeConfig(), secrets as any, upstream as any);
126
- await expect(r.call("get_me", {})).rejects.toThrow(/could not determine repo owner/);
127
- });
128
-
129
- it("errors when the owner has no configured credentials", async () => {
130
- const r = new Router(makeConfig(), secrets as any, upstream as any);
131
- await expect(r.call("get_me", { owner: "stranger" })).rejects.toThrow(/no credentials configured for owner "stranger"/);
132
- });
133
-
134
- it("re-throws (and audits) when the upstream forward fails", async () => {
135
- upstream.forward.mockRejectedValue(new Error("upstream boom"));
136
- const r = new Router(makeConfig(), secrets as any, upstream as any);
137
- await expect(r.call("get_me", { owner: "cldmv" })).rejects.toThrow(/upstream boom/);
138
- });
139
-
140
- describe("custom verbs", () => {
141
- it("link_issue is DEFERRED — dispatching it (even if allowlisted) is not implemented", async () => {
142
- // The catalog + router hooks for link_issue are commented out (App-token-only,
143
- // not wired in). So even with an allowlist entry it never dispatches — it falls
144
- // through to the not-implemented default, and never reaches the upstream.
145
- const r = new Router(makeConfig(), secrets as any, upstream as any);
146
- await expect(r.call("link_issue", { owner: "cldmv", repo: "x", pull: 1, issue: 2 })).rejects.toThrow(
147
- /custom verb "link_issue" not implemented/
148
- );
149
- expect(upstream.forward).not.toHaveBeenCalled();
150
- });
151
-
152
- it("resolve_review_thread runs on a write-PAT Octokit", async () => {
153
- graphqlMock.mockResolvedValue({ resolveReviewThread: { thread: { id: "PRRT_1", isResolved: true } } });
154
- const r = new Router(makeConfig(), secrets as any, upstream as any);
155
- const out = await r.call("resolve_review_thread", { owner: "cldmv", threadId: "PRRT_1" });
156
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-write" });
157
- expect(out).toEqual({ content: [{ type: "text", text: "thread PRRT_1: isResolved=true" }] });
158
- });
159
-
160
- it("npm_scaffold is unavailable without an npm block, before any credential is read", async () => {
161
- const r = new Router(makeConfig({ tools: [{ name: "npm_scaffold", mode: "write", custom: true }] }), secrets as any, upstream as any);
162
- await expect(r.call("npm_scaffold", { owner: "cldmv", repo: "x", package: "@cldmv/x", otp: "123456" })).rejects.toThrow(
163
- /tool "npm_scaffold" is not available on this broker: the npm integration is not configured/
164
- );
165
- expect(secrets.resolve).not.toHaveBeenCalled();
166
- });
167
-
168
- it("npm_scaffold is unavailable when its token is not stored", async () => {
169
- secrets.has.mockImplementation((ref: string) => ref !== "cred:npm-token");
170
- const cfg = makeConfig({ tools: [{ name: "npm_scaffold", mode: "write", custom: true }], npm: { token: "cred:npm-token", scopes: ["@cldmv"] } });
171
- const r = new Router(cfg, secrets as any, upstream as any);
172
- await expect(r.call("npm_scaffold", { owner: "cldmv", repo: "x", package: "@cldmv/x", otp: "123456" })).rejects.toThrow(/its credential "npm-token" is not stored/);
173
- expect(secrets.resolve).not.toHaveBeenCalled();
174
- });
175
-
176
- it("npm_scaffold reads GitHub on the READ PAT and resolves the npm token", async () => {
177
- requestMock.mockRejectedValue(Object.assign(new Error("Not Found"), { status: 404 }));
178
- const cfg = makeConfig({ tools: [{ name: "npm_scaffold", mode: "write", custom: true }], npm: { token: "cred:npm-token", scopes: ["@cldmv"] } });
179
- const r = new Router(cfg, secrets as any, upstream as any);
180
- await expect(r.call("npm_scaffold", { owner: "cldmv", repo: "x", package: "@cldmv/x", otp: "123456" })).rejects.toThrow(/repository cldmv\/x not found/);
181
- expect(Octokit).toHaveBeenLastCalledWith({ auth: "token:cred:cldmv-read" });
182
- expect(secrets.resolve).toHaveBeenCalledWith("cred:npm-token");
183
- });
184
-
185
- it("npm_scaffold publishes through the configured registry and returns the verb's result", async () => {
186
- const b64 = (x: string) => Buffer.from(x).toString("base64");
187
- requestMock.mockImplementation(async (route: string, p: any) => {
188
- if (route === "GET /repos/{owner}/{repo}") return { data: { full_name: "CLDMV/x", private: false, default_branch: "master" } };
189
- if (p.path === "package.json") return { data: { type: "file", encoding: "base64", content: b64('{"name":"@cldmv/x"}') } };
190
- return { data: { type: "file", encoding: "base64", content: b64("name: publish") } };
191
- });
192
- const urls: string[] = [];
193
- const fetchMock = vi.fn(async (url: string, init: any) => {
194
- urls.push(`${init.method} ${url}`);
195
- return { status: init.method === "GET" ? 404 : 200, headers: { get: () => null }, text: async () => "{}" };
196
- });
197
- vi.stubGlobal("fetch", fetchMock);
198
- try {
199
- const cfg = makeConfig({ tools: [{ name: "npm_scaffold", mode: "write", custom: true }], npm: { token: "cred:npm-token", scopes: ["@cldmv"], registry: "https://npm.example.test" } });
200
- const r = new Router(cfg, secrets as any, upstream as any);
201
- const out = await r.call("npm_scaffold", { owner: "cldmv", repo: "x", package: "@cldmv/x", otp: "123456" });
202
- expect(JSON.parse((out.content[0] as any).text)).toMatchObject({ status: "needs_otp", done: ["publish"], remaining: ["trust"] });
203
- expect(urls).toEqual(["GET https://npm.example.test/@cldmv%2fx?write=true", "PUT https://npm.example.test/@cldmv%2fx"]);
204
- expect(fetchMock.mock.calls[1]![1].headers.authorization).toBe("Bearer token:cred:npm-token");
205
- } finally {
206
- vi.unstubAllGlobals();
207
- }
208
- });
209
-
210
- it("throws for an allowlisted custom verb that has no implementation", async () => {
211
- const cfg = makeConfig({ tools: [{ name: "weird_verb", mode: "read", custom: true }] });
212
- const r = new Router(cfg, secrets as any, upstream as any);
213
- await expect(r.call("weird_verb", { owner: "cldmv" })).rejects.toThrow(/custom verb "weird_verb" not implemented/);
214
- });
215
-
216
- it("reply_review_thread runs on a write-PAT Octokit", async () => {
217
- graphqlMock.mockResolvedValue({ addPullRequestReviewThreadReply: { comment: { id: "C_1", url: "https://gh/c/1" } } });
218
- const r = new Router(makeConfig(), secrets as any, upstream as any);
219
- const out = await r.call("reply_review_thread", { owner: "cldmv", threadId: "PRRT_1", body: "thanks" });
220
- expect(out).toEqual({ content: [{ type: "text", text: "replied: https://gh/c/1" }] });
221
- });
222
-
223
- it("issue_labels runs on a write-PAT Octokit and returns the resulting label set as JSON text", async () => {
224
- requestMock.mockImplementation(async (route: string) => {
225
- if (route === "GET /repos/{owner}/{repo}/issues/{issue_number}/labels") return { data: [{ name: "type: bug" }] };
226
- if (route === "GET /repos/{owner}/{repo}/labels/{name}") return { data: { name: "area: auth" } };
227
- return { data: [{ name: "type: bug" }, { name: "area: auth" }] };
228
- });
229
- const r = new Router(makeConfig(), secrets as any, upstream as any);
230
- const out = await r.call("issue_labels", { owner: "CLDMV", repo: "gh-broker", issue_number: 117, add: "area: auth" });
231
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-write" });
232
- expect(JSON.parse((out.content[0] as { text: string }).text)).toEqual({
233
- issue: "CLDMV/gh-broker#117",
234
- labels: ["type: bug", "area: auth"],
235
- added: ["area: auth"],
236
- removed: [],
237
- created: []
238
- });
239
- expect(upstream.forward).not.toHaveBeenCalled();
240
- });
241
-
242
- it("report_unlabeled runs on a read-PAT Octokit and returns the JSON report as text", async () => {
243
- requestMock.mockResolvedValue({ data: [], headers: {} });
244
- const r = new Router(makeConfig(), secrets as any, upstream as any);
245
- const out = await r.call("report_unlabeled", { owner: "cldmv", repo: "gh-broker" });
246
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
247
- expect(out).toEqual({
248
- content: [{ type: "text", text: JSON.stringify({ scanned: { repos: 1, issues: 0, pulls: 0 }, unlabeled: [] }) }]
249
- });
250
- });
251
-
252
- it("report_security_alerts runs on a read-PAT Octokit and returns the JSON report as text", async () => {
253
- requestMock.mockResolvedValue({ data: [], headers: {} });
254
- const r = new Router(makeConfig(), secrets as any, upstream as any);
255
- const out = await r.call("report_security_alerts", { owner: "cldmv" });
256
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
257
- expect(out).toEqual({
258
- content: [
259
- {
260
- type: "text",
261
- text: JSON.stringify({
262
- scanned: {},
263
- scope: { dependabot: "org", codeql: "org", secrets: "org" },
264
- counts: { dependabot: {}, codeql: {}, secrets: {} },
265
- byRepo: [],
266
- alerts: []
267
- })
268
- }
269
- ]
270
- });
271
- });
272
-
273
- it("report_release_prs runs on a read-PAT Octokit and returns the JSON report as text", async () => {
274
- requestMock.mockResolvedValue({ data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} });
275
- const r = new Router(makeConfig(), secrets as any, upstream as any);
276
- const out = await r.call("report_release_prs", { owner: "cldmv" });
277
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
278
- expect(out).toEqual({
279
- content: [
280
- {
281
- type: "text",
282
- text: JSON.stringify({ scanned: { releasePrs: 0 }, summary: { ready: 0, needsChangelog: 0, unknownVersion: 0 }, releasePrs: [] })
283
- }
284
- ]
285
- });
286
- });
287
-
288
- it("report_ci_status runs on a read-PAT Octokit and returns the JSON report as text", async () => {
289
- requestMock.mockResolvedValueOnce({ data: { head: { sha: "abc" } }, headers: {} }).mockResolvedValueOnce({ data: { workflow_runs: [] }, headers: {} });
290
- const r = new Router(makeConfig(), secrets as any, upstream as any);
291
- const out = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5 });
292
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
293
- expect(out).toEqual({
294
- content: [{ type: "text", text: JSON.stringify({ pull: 5, summary: { passed: 0, failed: 0, pending: 0 } }) }]
295
- });
296
- });
297
-
298
- describe("report_ci_status tier-3 log fetch (via get_job_logs, not raw REST)", () => {
299
- const queuePrRunJob = () =>
300
- requestMock
301
- .mockResolvedValueOnce({ data: { head: { sha: "abc" } }, headers: {} })
302
- .mockResolvedValueOnce({ data: { workflow_runs: [{ id: 9, name: "CI", status: "completed", conclusion: "failure", html_url: "https://gh/run/9" }] }, headers: {} })
303
- .mockResolvedValueOnce({ data: { jobs: [{ id: 555, name: "test", status: "completed", conclusion: "failure", html_url: "https://gh/job/555", steps: [] }] }, headers: {} });
304
-
305
- it("calls get_job_logs with failed_only+run_id and attaches the matched job's log", async () => {
306
- queuePrRunJob();
307
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ logs: [{ job_id: 555, logs_content: "boom" }] }) }] });
308
- const r = new Router(makeConfig(), secrets as any, upstream as any);
309
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
310
- expect(upstream.forward).toHaveBeenCalledWith(
311
- "get_job_logs",
312
- { owner: "cldmv", repo: "gh-broker", run_id: 9, failed_only: true, return_content: true, tail_lines: 200 },
313
- "token:cred:cldmv-read"
314
- );
315
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBe("boom");
316
- });
317
-
318
- it("yields no logTail when get_job_logs' content isn't the expected JSON-text shape", async () => {
319
- queuePrRunJob();
320
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: "not json" }] });
321
- const r = new Router(makeConfig(), secrets as any, upstream as any);
322
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
323
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
324
- });
325
-
326
- it("yields no logTail when get_job_logs has no text content block at all", async () => {
327
- queuePrRunJob();
328
- upstream.forward.mockResolvedValue({ content: [] });
329
- const r = new Router(makeConfig(), secrets as any, upstream as any);
330
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
331
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
332
- });
333
-
334
- it("yields no logTail when get_job_logs itself throws (best-effort, not a hard failure)", async () => {
335
- queuePrRunJob();
336
- upstream.forward.mockRejectedValue(new Error("upstream boom"));
337
- const r = new Router(makeConfig(), secrets as any, upstream as any);
338
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
339
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
340
- });
341
-
342
- it("yields no logTail when the parsed response has no logs array at all", async () => {
343
- queuePrRunJob();
344
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ message: "no failed jobs" }) }] });
345
- const r = new Router(makeConfig(), secrets as any, upstream as any);
346
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
347
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
348
- });
349
-
350
- it("ignores a logs entry with a malformed job_id/logs_content", async () => {
351
- queuePrRunJob();
352
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ logs: [{ job_id: "not-a-number", logs_content: "x" }] }) }] });
353
- const r = new Router(makeConfig(), secrets as any, upstream as any);
354
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
355
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
356
- });
357
- });
358
- });
359
-
360
- describe("augmented tools", () => {
361
- it("forwards untouched when ciVerbosity is absent (opt-in only)", async () => {
362
- const r = new Router(makeConfig(), secrets as any, upstream as any);
363
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get" });
364
- expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
365
- expect(Octokit).not.toHaveBeenCalled();
366
- });
367
-
368
- it("forwards untouched when method isn't get, even with ciVerbosity present", async () => {
369
- const r = new Router(makeConfig(), secrets as any, upstream as any);
370
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get_diff", ciVerbosity: 1 });
371
- expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
372
- expect(upstream.forward).toHaveBeenCalledWith("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get_diff" }, "token:cred:cldmv-read");
373
- });
374
-
375
- it("injects a ci field computed from the PR's head sha when ciVerbosity is given", async () => {
376
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" } }) }] });
377
- requestMock.mockResolvedValueOnce({ data: { workflow_runs: [] }, headers: {} });
378
- const r = new Router(makeConfig(), secrets as any, upstream as any);
379
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get", ciVerbosity: 0 });
380
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
381
- // Strips gh-broker's own params before forwarding upstream.
382
- expect(upstream.forward).toHaveBeenCalledWith("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get" }, "token:cred:cldmv-read");
383
- expect(out).toEqual({
384
- content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" }, ci: { pull: 5, summary: { passed: 0, failed: 0, pending: 0 } } }) }]
385
- });
386
- });
387
-
388
- it("tier 3 on the augmented pull_request_read also fetches logs via get_job_logs", async () => {
389
- upstream.forward.mockImplementation(async (tool: string) => {
390
- if (tool === "pull_request_read") return { content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" } }) }] };
391
- if (tool === "get_job_logs") return { content: [{ type: "text", text: JSON.stringify({ logs: [{ job_id: 555, logs_content: "boom" }] }) }] };
392
- throw new Error(`unexpected upstream tool ${tool}`);
393
- });
394
- requestMock
395
- .mockResolvedValueOnce({ data: { workflow_runs: [{ id: 9, name: "CI", status: "completed", conclusion: "failure", html_url: "https://gh/run/9" }] }, headers: {} })
396
- .mockResolvedValueOnce({ data: { jobs: [{ id: 555, name: "test", status: "completed", conclusion: "failure", html_url: "https://gh/job/555", steps: [] }] }, headers: {} });
397
- const r = new Router(makeConfig(), secrets as any, upstream as any);
398
- const out: any = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get", ciVerbosity: 3 });
399
- expect(upstream.forward).toHaveBeenCalledWith(
400
- "get_job_logs",
401
- { owner: "cldmv", repo: "gh-broker", run_id: 9, failed_only: true, return_content: true, tail_lines: 200 },
402
- "token:cred:cldmv-read"
403
- );
404
- expect(JSON.parse(out.content[0].text).ci.failures[0].logTail).toBe("boom");
405
- });
406
-
407
- it("defaults to method 'get' (augments) when method is omitted entirely, and forwards a given logLines", async () => {
408
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" } }) }] });
409
- requestMock.mockResolvedValueOnce({ data: { workflow_runs: [] }, headers: {} });
410
- const r = new Router(makeConfig(), secrets as any, upstream as any);
411
- const out: any = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, ciVerbosity: 0, logLines: 50 });
412
- expect(JSON.parse(out.content[0].text).ci).toEqual({ pull: 5, summary: { passed: 0, failed: 0, pending: 0 } });
413
- });
414
-
415
- it("returns the upstream result untouched when there is no text content block at all", async () => {
416
- upstream.forward.mockResolvedValue({ content: [] });
417
- const r = new Router(makeConfig(), secrets as any, upstream as any);
418
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get", ciVerbosity: 0 });
419
- expect(out).toEqual({ content: [] });
420
- expect(Octokit).not.toHaveBeenCalled();
421
- });
422
-
423
- it("resolves a missing repo arg to an empty string rather than throwing", async () => {
424
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" } }) }] });
425
- requestMock.mockResolvedValueOnce({ data: { workflow_runs: [] }, headers: {} });
426
- const r = new Router(makeConfig(), secrets as any, upstream as any);
427
- const out: any = await r.call("pull_request_read", { owner: "cldmv", pullNumber: 5, method: "get", ciVerbosity: 0 });
428
- expect(JSON.parse(out.content[0].text).ci).toEqual({ pull: 5, summary: { passed: 0, failed: 0, pending: 0 } });
429
- });
430
-
431
- it("returns the upstream result untouched when its content isn't the expected JSON-text shape", async () => {
432
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: "not json" }] });
433
- const r = new Router(makeConfig(), secrets as any, upstream as any);
434
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get", ciVerbosity: 0 });
435
- expect(out).toEqual({ content: [{ type: "text", text: "not json" }] });
436
- expect(Octokit).not.toHaveBeenCalled();
437
- });
438
-
439
- it("just forwards an augment-flagged tool with no dispatchAugmented case for it", async () => {
440
- const cfg = makeConfig({ tools: [{ name: "weird_augment", mode: "read", augment: true }] });
441
- const r = new Router(cfg, secrets as any, upstream as any);
442
- const out = await r.call("weird_augment", { owner: "cldmv" });
443
- expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
444
- });
445
-
446
- it("returns the upstream result untouched when the parsed PR is missing number/head.sha", async () => {
447
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ title: "no head here" }) }] });
448
- const r = new Router(makeConfig(), secrets as any, upstream as any);
449
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get", ciVerbosity: 0 });
450
- expect(out).toEqual({ content: [{ type: "text", text: JSON.stringify({ title: "no head here" }) }] });
451
- expect(Octokit).not.toHaveBeenCalled();
452
- });
453
- });
454
- });
455
-
456
- describe("Router.call — GHCR verbs", () => {
457
- const ghcr = { dropDir: "/srv/gh-broker/ghcr-drop", protectReleaseTags: true, maxArchiveBytes: 1024, owners: { cldmv: { username: "u", credential: "cred:cldmv-packages", images: ["hoot"] } } };
458
- const tools = [
459
- { name: "ghcr_push", mode: "write", custom: true },
460
- { name: "ghcr_push_status", mode: "read", custom: true },
461
- { name: "ghcr_tags", mode: "read", custom: true }
462
- ];
463
- let secrets: { resolve: ReturnType<typeof vi.fn> };
464
- beforeEach(() => {
465
- vi.spyOn(console, "log").mockImplementation(() => {});
466
- secrets = { resolve: vi.fn((ref: string) => `token:${ref}`), has: vi.fn(() => true) };
467
- });
468
- afterEach(() => vi.restoreAllMocks());
469
-
470
- it("dispatches ghcr_push_status to the GHCR service and returns JSON", async () => {
471
- const r = new Router(makeConfig({ tools, ghcr }), secrets as any, { forward: vi.fn() } as any);
472
- expect(await r.call("ghcr_push_status", { owner: "cldmv" })).toEqual({ content: [{ type: "text", text: JSON.stringify({ jobs: [] }) }] });
473
- });
474
-
475
- it("passes GHCR refusals through (allowlist checked before any network call or GHCR credential read)", async () => {
476
- const r = new Router(makeConfig({ tools, ghcr }), secrets as any, { forward: vi.fn() } as any);
477
- await expect(r.call("ghcr_tags", { owner: "cldmv", image: "private" })).rejects.toThrow(/not on the allowlist/);
478
- await expect(r.call("ghcr_push", { owner: "cldmv", image: "private", tag: "t", archive: "a.tar" })).rejects.toThrow(/not on the allowlist/);
479
- expect(secrets.resolve).not.toHaveBeenCalledWith("cred:cldmv-packages");
480
- });
481
-
482
- it("audits and rethrows a GHCR call that names no owner", async () => {
483
- const r = new Router(makeConfig({ tools, ghcr }), secrets as any, { forward: vi.fn() } as any);
484
- await expect(r.call("ghcr_push_status", {})).rejects.toThrow();
485
- });
486
-
487
- it("refuses GHCR verbs when config.ghcr is absent", async () => {
488
- const r = new Router(makeConfig({ tools }), secrets as any, { forward: vi.fn() } as any);
489
- await expect(r.call("ghcr_push", { owner: "cldmv", image: "hoot", tag: "t", archive: "a" })).rejects.toThrow(/tool "ghcr_push" is not available on this broker: GHCR pushes are not configured/);
490
- });
491
-
492
- it("refuses an owner whose GHCR credential is not stored, while another owner's is", async () => {
493
- const ghcr = {
494
- dropDir: "/srv/x/drop",
495
- protectReleaseTags: true,
496
- maxArchiveBytes: 1,
497
- owners: { cldmv: { username: "u", credential: "cred:cldmv-packages", images: ["hoot"] }, other: { username: "o", credential: "cred:other-packages", images: ["x"] } }
498
- };
499
- secrets.has.mockImplementation((ref: string) => ref === "cred:other-packages");
500
- const r = new Router(makeConfig({ tools, ghcr }), secrets as any, { forward: vi.fn() } as any);
501
- await expect(r.call("ghcr_push_status", { owner: "cldmv" })).rejects.toThrow(/owner "cldmv"'s GHCR credential "cldmv-packages" is not stored/);
502
- await expect(r.call("ghcr_push_status", { owner: "other" })).resolves.toMatchObject({ content: [{ type: "text" }] });
503
- expect(secrets.resolve).not.toHaveBeenCalledWith("cred:cldmv-packages");
504
- });
505
- });