@cldmv/gh-broker 0.7.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 (107) hide show
  1. package/README.md +165 -41
  2. package/config.example.json +38 -9
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +76 -17
  5. package/deploy/install.mjs +171 -104
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +5864 -1809
  14. package/dist/index.mjs.map +4 -4
  15. package/package.json +18 -8
  16. package/.configs/fix-headers.json +0 -3
  17. package/.configs/vitest.config.mjs +0 -44
  18. package/.github/dependabot.yml +0 -111
  19. package/.github/workflows/branch-retention.yml +0 -44
  20. package/.github/workflows/bundle-size.yml +0 -52
  21. package/.github/workflows/ci.yml +0 -389
  22. package/.github/workflows/cla.yml +0 -66
  23. package/.github/workflows/codeql.yml +0 -87
  24. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  25. package/.github/workflows/dependabot-recreate.yml +0 -67
  26. package/.github/workflows/dependency-review.yml +0 -46
  27. package/.github/workflows/feature-pr.yml +0 -75
  28. package/.github/workflows/hotfix-redirector.yml +0 -67
  29. package/.github/workflows/hotfixes-release.yml +0 -70
  30. package/.github/workflows/labeler.yml +0 -48
  31. package/.github/workflows/master-commit-audit.yml +0 -56
  32. package/.github/workflows/member-auto-merge.yml +0 -75
  33. package/.github/workflows/next-release.yml +0 -76
  34. package/.github/workflows/next-reset.yml +0 -50
  35. package/.github/workflows/pr-notify.yml +0 -44
  36. package/.github/workflows/pr-title-normalizer.yml +0 -52
  37. package/.github/workflows/provenance.yml +0 -51
  38. package/.github/workflows/publish.yml +0 -146
  39. package/.github/workflows/release-merge.yml +0 -104
  40. package/.github/workflows/release-notify.yml +0 -41
  41. package/.github/workflows/scorecard.yml +0 -64
  42. package/.github/workflows/stale.yml +0 -50
  43. package/.github/workflows/tag-health.yml +0 -70
  44. package/.github/workflows/update-major-version-tags.yml +0 -94
  45. package/.github/workflows/v4-bootstrap.yml +0 -115
  46. package/.github/workflows/welcome.yml +0 -42
  47. package/docs/TOOLS.md +0 -347
  48. package/docs/agent-user-model.md +0 -89
  49. package/docs/changelog/v0/v0.2.0.md +0 -26
  50. package/docs/changelog/v0/v0.2.1.md +0 -23
  51. package/docs/changelog/v0/v0.2.2.md +0 -23
  52. package/docs/changelog/v0/v0.2.3.md +0 -33
  53. package/docs/changelog/v0/v0.2.4.md +0 -25
  54. package/docs/changelog/v0/v0.2.5.md +0 -29
  55. package/docs/changelog/v0/v0.3.0.md +0 -84
  56. package/docs/changelog/v0/v0.3.1.md +0 -30
  57. package/docs/changelog/v0/v0.3.2.md +0 -24
  58. package/docs/changelog/v0/v0.3.3.md +0 -28
  59. package/docs/changelog/v0/v0.4.0.md +0 -58
  60. package/docs/changelog/v0/v0.4.1.md +0 -33
  61. package/docs/changelog/v0/v0.4.2.md +0 -45
  62. package/docs/changelog/v0/v0.5.0.md +0 -39
  63. package/docs/changelog/v0/v0.5.1.md +0 -28
  64. package/docs/changelog/v0/v0.6.0.md +0 -52
  65. package/docs/changelog/v0/v0.6.1.md +0 -48
  66. package/docs/changelog/v0/v0.7.0.md +0 -71
  67. package/docs/deploy-architecture.md +0 -82
  68. package/src/appauth.ts +0 -56
  69. package/src/audit.ts +0 -34
  70. package/src/catalog.ts +0 -165
  71. package/src/config.ts +0 -130
  72. package/src/index.ts +0 -107
  73. package/src/paths.ts +0 -60
  74. package/src/router.ts +0 -258
  75. package/src/secrets.ts +0 -137
  76. package/src/server.ts +0 -91
  77. package/src/types.ts +0 -104
  78. package/src/upstream.ts +0 -85
  79. package/src/verbs/issue_labels.ts +0 -329
  80. package/src/verbs/link_issue.ts +0 -84
  81. package/src/verbs/release-analysis.ts +0 -210
  82. package/src/verbs/repo-tier.ts +0 -90
  83. package/src/verbs/report_ci_status.ts +0 -358
  84. package/src/verbs/report_release_prs.ts +0 -238
  85. package/src/verbs/report_security_alerts.ts +0 -442
  86. package/src/verbs/report_unlabeled.ts +0 -194
  87. package/src/verbs/rest-client.ts +0 -181
  88. package/src/verbs/review_thread.ts +0 -138
  89. package/tests/appauth.test.vitest.mts +0 -76
  90. package/tests/audit.test.vitest.mts +0 -48
  91. package/tests/catalog.test.vitest.mts +0 -180
  92. package/tests/config-merge.test.vitest.mts +0 -58
  93. package/tests/config.test.vitest.mts +0 -314
  94. package/tests/issue-labels.test.vitest.mts +0 -347
  95. package/tests/paths.test.vitest.mts +0 -52
  96. package/tests/release-prs.test.vitest.mts +0 -237
  97. package/tests/rest-client.test.vitest.mts +0 -115
  98. package/tests/router.test.vitest.mts +0 -399
  99. package/tests/run-vitest.mjs +0 -51
  100. package/tests/secret-store.test.vitest.mts +0 -106
  101. package/tests/secrets.test.vitest.mts +0 -155
  102. package/tests/security-alerts.test.vitest.mts +0 -328
  103. package/tests/server.test.vitest.mts +0 -20
  104. package/tests/upstream.test.vitest.mts +0 -91
  105. package/tests/verb-helpers.test.vitest.mts +0 -331
  106. package/tests/verbs.test.vitest.mts +0 -624
  107. 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,399 +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}`) };
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()).toBe(cfg.tools);
91
- });
92
-
93
- it("forwards a read call with the resolved read PAT", async () => {
94
- const r = new Router(makeConfig(), secrets as any, upstream as any);
95
- const out = await r.call("get_me", { owner: "CLDMV" });
96
- expect(secrets.resolve).toHaveBeenCalledWith("cred:cldmv-read");
97
- expect(upstream.forward).toHaveBeenCalledWith("get_me", { owner: "CLDMV" }, "token:cred:cldmv-read");
98
- expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
99
- });
100
-
101
- it("uses the write PAT for a write-mode tool", async () => {
102
- const r = new Router(makeConfig(), secrets as any, upstream as any);
103
- await r.call("create_pull_request", { owner: "cldmv", repo: "gh-broker" });
104
- expect(secrets.resolve).toHaveBeenCalledWith("cred:cldmv-write");
105
- });
106
-
107
- it("falls back an owner-less READ to defaultOwner's read PAT", async () => {
108
- const r = new Router(makeConfig({ defaultOwner: "cldmv" }), secrets as any, upstream as any);
109
- await r.call("get_me", {});
110
- expect(secrets.resolve).toHaveBeenCalledWith("cred:cldmv-read");
111
- expect(upstream.forward).toHaveBeenCalledWith("get_me", {}, "token:cred:cldmv-read");
112
- });
113
-
114
- it("does NOT fall back an owner-less WRITE, even with defaultOwner set", async () => {
115
- const r = new Router(makeConfig({ defaultOwner: "cldmv" }), secrets as any, upstream as any);
116
- await expect(r.call("create_pull_request", {})).rejects.toThrow(/could not determine repo owner/);
117
- });
118
-
119
- it("errors on an owner-less read when there is no defaultOwner", async () => {
120
- const r = new Router(makeConfig(), secrets as any, upstream as any);
121
- await expect(r.call("get_me", {})).rejects.toThrow(/could not determine repo owner/);
122
- });
123
-
124
- it("errors when the owner has no configured credentials", async () => {
125
- const r = new Router(makeConfig(), secrets as any, upstream as any);
126
- await expect(r.call("get_me", { owner: "stranger" })).rejects.toThrow(/no credentials configured for owner "stranger"/);
127
- });
128
-
129
- it("re-throws (and audits) when the upstream forward fails", async () => {
130
- upstream.forward.mockRejectedValue(new Error("upstream boom"));
131
- const r = new Router(makeConfig(), secrets as any, upstream as any);
132
- await expect(r.call("get_me", { owner: "cldmv" })).rejects.toThrow(/upstream boom/);
133
- });
134
-
135
- describe("custom verbs", () => {
136
- it("link_issue is DEFERRED — dispatching it (even if allowlisted) is not implemented", async () => {
137
- // The catalog + router hooks for link_issue are commented out (App-token-only,
138
- // not wired in). So even with an allowlist entry it never dispatches — it falls
139
- // through to the not-implemented default, and never reaches the upstream.
140
- const r = new Router(makeConfig(), secrets as any, upstream as any);
141
- await expect(r.call("link_issue", { owner: "cldmv", repo: "x", pull: 1, issue: 2 })).rejects.toThrow(
142
- /custom verb "link_issue" not implemented/
143
- );
144
- expect(upstream.forward).not.toHaveBeenCalled();
145
- });
146
-
147
- it("resolve_review_thread runs on a write-PAT Octokit", async () => {
148
- graphqlMock.mockResolvedValue({ resolveReviewThread: { thread: { id: "PRRT_1", isResolved: true } } });
149
- const r = new Router(makeConfig(), secrets as any, upstream as any);
150
- const out = await r.call("resolve_review_thread", { owner: "cldmv", threadId: "PRRT_1" });
151
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-write" });
152
- expect(out).toEqual({ content: [{ type: "text", text: "thread PRRT_1: isResolved=true" }] });
153
- });
154
-
155
- it("throws for an allowlisted custom verb that has no implementation", async () => {
156
- const cfg = makeConfig({ tools: [{ name: "weird_verb", mode: "read", custom: true }] });
157
- const r = new Router(cfg, secrets as any, upstream as any);
158
- await expect(r.call("weird_verb", { owner: "cldmv" })).rejects.toThrow(/custom verb "weird_verb" not implemented/);
159
- });
160
-
161
- it("reply_review_thread runs on a write-PAT Octokit", async () => {
162
- graphqlMock.mockResolvedValue({ addPullRequestReviewThreadReply: { comment: { id: "C_1", url: "https://gh/c/1" } } });
163
- const r = new Router(makeConfig(), secrets as any, upstream as any);
164
- const out = await r.call("reply_review_thread", { owner: "cldmv", threadId: "PRRT_1", body: "thanks" });
165
- expect(out).toEqual({ content: [{ type: "text", text: "replied: https://gh/c/1" }] });
166
- });
167
-
168
- it("issue_labels runs on a write-PAT Octokit and returns the resulting label set as JSON text", async () => {
169
- requestMock.mockImplementation(async (route: string) => {
170
- if (route === "GET /repos/{owner}/{repo}/issues/{issue_number}/labels") return { data: [{ name: "type: bug" }] };
171
- if (route === "GET /repos/{owner}/{repo}/labels/{name}") return { data: { name: "area: auth" } };
172
- return { data: [{ name: "type: bug" }, { name: "area: auth" }] };
173
- });
174
- const r = new Router(makeConfig(), secrets as any, upstream as any);
175
- const out = await r.call("issue_labels", { owner: "CLDMV", repo: "gh-broker", issue_number: 117, add: "area: auth" });
176
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-write" });
177
- expect(JSON.parse((out.content[0] as { text: string }).text)).toEqual({
178
- issue: "CLDMV/gh-broker#117",
179
- labels: ["type: bug", "area: auth"],
180
- added: ["area: auth"],
181
- removed: [],
182
- created: []
183
- });
184
- expect(upstream.forward).not.toHaveBeenCalled();
185
- });
186
-
187
- it("report_unlabeled runs on a read-PAT Octokit and returns the JSON report as text", async () => {
188
- requestMock.mockResolvedValue({ data: [], headers: {} });
189
- const r = new Router(makeConfig(), secrets as any, upstream as any);
190
- const out = await r.call("report_unlabeled", { owner: "cldmv", repo: "gh-broker" });
191
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
192
- expect(out).toEqual({
193
- content: [{ type: "text", text: JSON.stringify({ scanned: { repos: 1, issues: 0, pulls: 0 }, unlabeled: [] }) }]
194
- });
195
- });
196
-
197
- it("report_security_alerts runs on a read-PAT Octokit and returns the JSON report as text", async () => {
198
- requestMock.mockResolvedValue({ data: [], headers: {} });
199
- const r = new Router(makeConfig(), secrets as any, upstream as any);
200
- const out = await r.call("report_security_alerts", { owner: "cldmv" });
201
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
202
- expect(out).toEqual({
203
- content: [
204
- {
205
- type: "text",
206
- text: JSON.stringify({
207
- scanned: {},
208
- scope: { dependabot: "org", codeql: "org", secrets: "org" },
209
- counts: { dependabot: {}, codeql: {}, secrets: {} },
210
- byRepo: [],
211
- alerts: []
212
- })
213
- }
214
- ]
215
- });
216
- });
217
-
218
- it("report_release_prs runs on a read-PAT Octokit and returns the JSON report as text", async () => {
219
- requestMock.mockResolvedValue({ data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} });
220
- const r = new Router(makeConfig(), secrets as any, upstream as any);
221
- const out = await r.call("report_release_prs", { owner: "cldmv" });
222
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
223
- expect(out).toEqual({
224
- content: [
225
- {
226
- type: "text",
227
- text: JSON.stringify({ scanned: { releasePrs: 0 }, summary: { ready: 0, needsChangelog: 0, unknownVersion: 0 }, releasePrs: [] })
228
- }
229
- ]
230
- });
231
- });
232
-
233
- it("report_ci_status runs on a read-PAT Octokit and returns the JSON report as text", async () => {
234
- requestMock.mockResolvedValueOnce({ data: { head: { sha: "abc" } }, headers: {} }).mockResolvedValueOnce({ data: { workflow_runs: [] }, headers: {} });
235
- const r = new Router(makeConfig(), secrets as any, upstream as any);
236
- const out = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5 });
237
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
238
- expect(out).toEqual({
239
- content: [{ type: "text", text: JSON.stringify({ pull: 5, summary: { passed: 0, failed: 0, pending: 0 } }) }]
240
- });
241
- });
242
-
243
- describe("report_ci_status tier-3 log fetch (via get_job_logs, not raw REST)", () => {
244
- const queuePrRunJob = () =>
245
- requestMock
246
- .mockResolvedValueOnce({ data: { head: { sha: "abc" } }, headers: {} })
247
- .mockResolvedValueOnce({ data: { workflow_runs: [{ id: 9, name: "CI", status: "completed", conclusion: "failure", html_url: "https://gh/run/9" }] }, headers: {} })
248
- .mockResolvedValueOnce({ data: { jobs: [{ id: 555, name: "test", status: "completed", conclusion: "failure", html_url: "https://gh/job/555", steps: [] }] }, headers: {} });
249
-
250
- it("calls get_job_logs with failed_only+run_id and attaches the matched job's log", async () => {
251
- queuePrRunJob();
252
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ logs: [{ job_id: 555, logs_content: "boom" }] }) }] });
253
- const r = new Router(makeConfig(), secrets as any, upstream as any);
254
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
255
- expect(upstream.forward).toHaveBeenCalledWith(
256
- "get_job_logs",
257
- { owner: "cldmv", repo: "gh-broker", run_id: 9, failed_only: true, return_content: true, tail_lines: 200 },
258
- "token:cred:cldmv-read"
259
- );
260
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBe("boom");
261
- });
262
-
263
- it("yields no logTail when get_job_logs' content isn't the expected JSON-text shape", async () => {
264
- queuePrRunJob();
265
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: "not json" }] });
266
- const r = new Router(makeConfig(), secrets as any, upstream as any);
267
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
268
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
269
- });
270
-
271
- it("yields no logTail when get_job_logs has no text content block at all", async () => {
272
- queuePrRunJob();
273
- upstream.forward.mockResolvedValue({ content: [] });
274
- const r = new Router(makeConfig(), secrets as any, upstream as any);
275
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
276
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
277
- });
278
-
279
- it("yields no logTail when get_job_logs itself throws (best-effort, not a hard failure)", async () => {
280
- queuePrRunJob();
281
- upstream.forward.mockRejectedValue(new Error("upstream boom"));
282
- const r = new Router(makeConfig(), secrets as any, upstream as any);
283
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
284
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
285
- });
286
-
287
- it("yields no logTail when the parsed response has no logs array at all", async () => {
288
- queuePrRunJob();
289
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ message: "no failed jobs" }) }] });
290
- const r = new Router(makeConfig(), secrets as any, upstream as any);
291
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
292
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
293
- });
294
-
295
- it("ignores a logs entry with a malformed job_id/logs_content", async () => {
296
- queuePrRunJob();
297
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ logs: [{ job_id: "not-a-number", logs_content: "x" }] }) }] });
298
- const r = new Router(makeConfig(), secrets as any, upstream as any);
299
- const out: any = await r.call("report_ci_status", { owner: "cldmv", repo: "gh-broker", pull: 5, verbosity: 3 });
300
- expect(JSON.parse(out.content[0].text).failures[0].logTail).toBeUndefined();
301
- });
302
- });
303
- });
304
-
305
- describe("augmented tools", () => {
306
- it("forwards untouched when ciVerbosity is absent (opt-in only)", async () => {
307
- const r = new Router(makeConfig(), secrets as any, upstream as any);
308
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get" });
309
- expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
310
- expect(Octokit).not.toHaveBeenCalled();
311
- });
312
-
313
- it("forwards untouched when method isn't get, even with ciVerbosity present", async () => {
314
- const r = new Router(makeConfig(), secrets as any, upstream as any);
315
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get_diff", ciVerbosity: 1 });
316
- expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
317
- expect(upstream.forward).toHaveBeenCalledWith("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get_diff" }, "token:cred:cldmv-read");
318
- });
319
-
320
- it("injects a ci field computed from the PR's head sha when ciVerbosity is given", async () => {
321
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" } }) }] });
322
- requestMock.mockResolvedValueOnce({ data: { workflow_runs: [] }, headers: {} });
323
- const r = new Router(makeConfig(), secrets as any, upstream as any);
324
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get", ciVerbosity: 0 });
325
- expect(Octokit).toHaveBeenCalledWith({ auth: "token:cred:cldmv-read" });
326
- // Strips gh-broker's own params before forwarding upstream.
327
- expect(upstream.forward).toHaveBeenCalledWith("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get" }, "token:cred:cldmv-read");
328
- expect(out).toEqual({
329
- content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" }, ci: { pull: 5, summary: { passed: 0, failed: 0, pending: 0 } } }) }]
330
- });
331
- });
332
-
333
- it("tier 3 on the augmented pull_request_read also fetches logs via get_job_logs", async () => {
334
- upstream.forward.mockImplementation(async (tool: string) => {
335
- if (tool === "pull_request_read") return { content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" } }) }] };
336
- if (tool === "get_job_logs") return { content: [{ type: "text", text: JSON.stringify({ logs: [{ job_id: 555, logs_content: "boom" }] }) }] };
337
- throw new Error(`unexpected upstream tool ${tool}`);
338
- });
339
- requestMock
340
- .mockResolvedValueOnce({ data: { workflow_runs: [{ id: 9, name: "CI", status: "completed", conclusion: "failure", html_url: "https://gh/run/9" }] }, headers: {} })
341
- .mockResolvedValueOnce({ data: { jobs: [{ id: 555, name: "test", status: "completed", conclusion: "failure", html_url: "https://gh/job/555", steps: [] }] }, headers: {} });
342
- const r = new Router(makeConfig(), secrets as any, upstream as any);
343
- const out: any = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get", ciVerbosity: 3 });
344
- expect(upstream.forward).toHaveBeenCalledWith(
345
- "get_job_logs",
346
- { owner: "cldmv", repo: "gh-broker", run_id: 9, failed_only: true, return_content: true, tail_lines: 200 },
347
- "token:cred:cldmv-read"
348
- );
349
- expect(JSON.parse(out.content[0].text).ci.failures[0].logTail).toBe("boom");
350
- });
351
-
352
- it("defaults to method 'get' (augments) when method is omitted entirely, and forwards a given logLines", async () => {
353
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" } }) }] });
354
- requestMock.mockResolvedValueOnce({ data: { workflow_runs: [] }, headers: {} });
355
- const r = new Router(makeConfig(), secrets as any, upstream as any);
356
- const out: any = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, ciVerbosity: 0, logLines: 50 });
357
- expect(JSON.parse(out.content[0].text).ci).toEqual({ pull: 5, summary: { passed: 0, failed: 0, pending: 0 } });
358
- });
359
-
360
- it("returns the upstream result untouched when there is no text content block at all", async () => {
361
- upstream.forward.mockResolvedValue({ content: [] });
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", ciVerbosity: 0 });
364
- expect(out).toEqual({ content: [] });
365
- expect(Octokit).not.toHaveBeenCalled();
366
- });
367
-
368
- it("resolves a missing repo arg to an empty string rather than throwing", async () => {
369
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ number: 5, head: { sha: "abc123" } }) }] });
370
- requestMock.mockResolvedValueOnce({ data: { workflow_runs: [] }, headers: {} });
371
- const r = new Router(makeConfig(), secrets as any, upstream as any);
372
- const out: any = await r.call("pull_request_read", { owner: "cldmv", pullNumber: 5, method: "get", ciVerbosity: 0 });
373
- expect(JSON.parse(out.content[0].text).ci).toEqual({ pull: 5, summary: { passed: 0, failed: 0, pending: 0 } });
374
- });
375
-
376
- it("returns the upstream result untouched when its content isn't the expected JSON-text shape", async () => {
377
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: "not json" }] });
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(out).toEqual({ content: [{ type: "text", text: "not json" }] });
381
- expect(Octokit).not.toHaveBeenCalled();
382
- });
383
-
384
- it("just forwards an augment-flagged tool with no dispatchAugmented case for it", async () => {
385
- const cfg = makeConfig({ tools: [{ name: "weird_augment", mode: "read", augment: true }] });
386
- const r = new Router(cfg, secrets as any, upstream as any);
387
- const out = await r.call("weird_augment", { owner: "cldmv" });
388
- expect(out).toEqual({ content: [{ type: "text", text: "ok" }] });
389
- });
390
-
391
- it("returns the upstream result untouched when the parsed PR is missing number/head.sha", async () => {
392
- upstream.forward.mockResolvedValue({ content: [{ type: "text", text: JSON.stringify({ title: "no head here" }) }] });
393
- const r = new Router(makeConfig(), secrets as any, upstream as any);
394
- const out = await r.call("pull_request_read", { owner: "cldmv", repo: "gh-broker", pullNumber: 5, method: "get", ciVerbosity: 0 });
395
- expect(out).toEqual({ content: [{ type: "text", text: JSON.stringify({ title: "no head here" }) }] });
396
- expect(Octokit).not.toHaveBeenCalled();
397
- });
398
- });
399
- });
@@ -1,51 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /tests/run-vitest.mjs
5
- * @Date: 2026-08-02T16:55:52-07:00 (1785714952)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- /**
17
- * @fileoverview OOM-safe Vitest runner — delegates to @cldmv/vitest-runner, which
18
- * spawns each test file in its own child process and (under coverage) uses a
19
- * blob-per-file + `--mergeReports` strategy so a single process never holds
20
- * coverage for the whole suite.
21
- */
22
- import path from "node:path";
23
- import { fileURLToPath } from "node:url";
24
- import { run } from "@cldmv/vitest-runner";
25
-
26
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
27
- const argv = process.argv.slice(2);
28
-
29
- const delimiter = argv.indexOf("--");
30
- const forwarded = delimiter === -1 ? argv.filter((a) => a.startsWith("-")) : argv.slice(0, delimiter);
31
- const testPatterns = delimiter === -1 ? argv.filter((a) => !a.startsWith("-")) : argv.slice(delimiter + 1);
32
-
33
- const coverageQuiet = forwarded.includes("--coverage-quiet");
34
- const coverage = coverageQuiet || forwarded.includes("--coverage");
35
- const passthrough = forwarded.filter((a) => a !== "--coverage" && a !== "--coverage-quiet");
36
-
37
- const parsedWorkers = parseInt(process.env.VITEST_WORKERS ?? "", 10);
38
- const workers = Number.isInteger(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : 4;
39
-
40
- const code = await run({
41
- cwd: root,
42
- testDir: "tests",
43
- vitestConfig: ".configs/vitest.config.mjs",
44
- testFilePattern: /\.test\.vitest\.mts$/,
45
- testPatterns,
46
- workers,
47
- coverageQuiet,
48
- vitestArgs: [...(coverage ? ["--coverage"] : []), ...passthrough],
49
- nodeEnv: process.env.NODE_ENV || "development"
50
- });
51
- process.exit(code);