@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,181 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/rest-client.ts
5
- * @Date: 2026-09-29T02:39:40+00:00 (1790649580)
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
- import type { Octokit } from "@octokit/core";
17
-
18
- /**
19
- * Shared REST-call machinery for custom verbs that compose plain `octokit.request()`
20
- * calls instead of proxying a hosted-MCP tool (currently `report_unlabeled` and
21
- * `report_ci_status`). Factored out once a second verb needed the identical
22
- * rate-limit/call-budget handling — see report_unlabeled.ts's original version for
23
- * the rationale (kept here, not duplicated).
24
- *
25
- * Rate-limit floor: proactively stops once remaining requests drop to 2% of the
26
- * token's OWN limit (read from `x-ratelimit-limit` on every response — never
27
- * hardcode GitHub's number, it can differ by token type/tier), floored at an
28
- * absolute minimum for low-limit tokens. Leaves headroom for other broker traffic
29
- * sharing the same PAT, rather than waiting to actually get a 403.
30
- */
31
-
32
- const PAGE_SIZE = 100;
33
- const RATE_LIMIT_FLOOR_RATIO = 0.02;
34
- const RATE_LIMIT_FLOOR_MIN = 20;
35
-
36
- export class RateLimited extends Error {
37
- readonly retryAfterSeconds?: number;
38
- constructor(retryAfterSeconds?: number) {
39
- super("rate limited");
40
- this.retryAfterSeconds = retryAfterSeconds;
41
- }
42
- }
43
-
44
- export class CallBudgetExhausted extends Error {
45
- constructor() {
46
- super("call budget exhausted");
47
- }
48
- }
49
-
50
- export class CallBudget {
51
- private remaining: number;
52
- constructor(max: number) {
53
- this.remaining = max;
54
- }
55
- /** Throws CallBudgetExhausted once the budget is spent. */
56
- spend(): void {
57
- if (this.remaining <= 0) throw new CallBudgetExhausted();
58
- this.remaining--;
59
- }
60
- }
61
-
62
- /**
63
- * A 403 is only a rate limit when GitHub says so — a Retry-After header, an exhausted
64
- * `x-ratelimit-remaining`, or a rate-limit/abuse message. Any other 403 is a permission
65
- * or feature-disabled answer (e.g. code scanning off for a repo) and must surface to the
66
- * caller with its status intact instead of being mistaken for throttling.
67
- */
68
- function looksRateLimited(err: Error, headers: Record<string, string> | undefined): boolean {
69
- if (headers?.["retry-after"] !== undefined) return true;
70
- if (headers?.["x-ratelimit-remaining"] === "0") return true;
71
- return /rate limit|abuse|secondary/i.test(err.message);
72
- }
73
-
74
- /** Runs one octokit.request call, charging the budget and enforcing the rate-limit floor. */
75
- async function doRequest(octokit: Octokit, budget: CallBudget, route: string, params: Record<string, unknown>): Promise<{ data: unknown }> {
76
- budget.spend();
77
- try {
78
- const res = await octokit.request(route, params);
79
- const headers = res.headers as Record<string, string> | undefined;
80
- const remainingHeader = headers?.["x-ratelimit-remaining"];
81
- if (remainingHeader !== undefined) {
82
- const remaining = Number(remainingHeader);
83
- const limit = Number(headers?.["x-ratelimit-limit"]);
84
- const floor = Number.isFinite(limit) ? Math.max(RATE_LIMIT_FLOOR_MIN, Math.ceil(limit * RATE_LIMIT_FLOOR_RATIO)) : RATE_LIMIT_FLOOR_MIN;
85
- if (remaining <= floor) throw new RateLimited();
86
- }
87
- return res;
88
- } catch (err) {
89
- if (err instanceof RateLimited) throw err;
90
- const status = (err as { status?: number }).status;
91
- const headers = (err as { response?: { headers?: Record<string, string> } }).response?.headers;
92
- if (status === 429 || (status === 403 && looksRateLimited(err as Error, headers))) {
93
- const retryAfter = headers?.["retry-after"];
94
- throw new RateLimited(retryAfter ? Number(retryAfter) : undefined);
95
- }
96
- throw err;
97
- }
98
- }
99
-
100
- /**
101
- * Runs one octokit.request call expected to return a listing. `extract` pulls the
102
- * item array out of the response body — plain for bare-array endpoints
103
- * (issues/pulls/repos), overridden for endpoints that wrap the array in a named
104
- * field (e.g. Actions: `{ workflow_runs: [...] }`).
105
- */
106
- export async function req<T>(
107
- octokit: Octokit,
108
- budget: CallBudget,
109
- route: string,
110
- params: Record<string, unknown>,
111
- extract: (data: unknown) => T[] = (d) => d as T[]
112
- ): Promise<T[]> {
113
- const res = await doRequest(octokit, budget, route, params);
114
- return extract(res.data);
115
- }
116
-
117
- /** Runs one octokit.request call expected to return a single resource (not a listing). */
118
- export async function reqOne<T>(octokit: Octokit, budget: CallBudget, route: string, params: Record<string, unknown>): Promise<T> {
119
- const res = await doRequest(octokit, budget, route, params);
120
- return res.data as T;
121
- }
122
-
123
- /** Paginates a listing endpoint up to `limit` items, stopping at a short last page. */
124
- export async function paginate<T>(
125
- octokit: Octokit,
126
- budget: CallBudget,
127
- route: string,
128
- params: Record<string, unknown>,
129
- limit: number,
130
- extract?: (data: unknown) => T[]
131
- ): Promise<T[]> {
132
- const out: T[] = [];
133
- let page = 1;
134
- while (out.length < limit) {
135
- const batch = await req<T>(octokit, budget, route, { ...params, per_page: PAGE_SIZE, page }, extract);
136
- if (batch.length === 0) break;
137
- out.push(...batch);
138
- if (batch.length < PAGE_SIZE) break;
139
- page++;
140
- }
141
- return out.length > limit ? out.slice(0, limit) : out;
142
- }
143
-
144
- /**
145
- * Paginates a listing endpoint that uses cursor pagination (`after`) instead of `page` —
146
- * Dependabot's alert endpoints. Follows the `rel="next"` cursor in the Link header and
147
- * stops at the last page or once `limit` items are collected.
148
- */
149
- export async function paginateAfter<T>(octokit: Octokit, budget: CallBudget, route: string, params: Record<string, unknown>, limit: number): Promise<T[]> {
150
- const out: T[] = [];
151
- let after: string | undefined;
152
- while (out.length < limit) {
153
- const res = await doRequest(octokit, budget, route, { ...params, per_page: PAGE_SIZE, ...(after ? { after } : {}) });
154
- const batch = res.data;
155
- if (!Array.isArray(batch) || batch.length === 0) break;
156
- out.push(...(batch as T[]));
157
- const link = (res as { headers?: Record<string, string> }).headers?.link;
158
- const next = link ? /<[^>]*[?&]after=([^&>]+)[^>]*>;\s*rel="next"/.exec(link) : null;
159
- if (!next) break;
160
- after = decodeURIComponent(next[1]!);
161
- }
162
- return out.length > limit ? out.slice(0, limit) : out;
163
- }
164
-
165
- interface RawRepo {
166
- name: string;
167
- archived?: boolean;
168
- }
169
-
170
- /** Lists non-archived repo names for an owner — tries the org endpoint, falls back to user. */
171
- export async function listOwnerRepos(octokit: Octokit, budget: CallBudget, owner: string, maxRepos: number): Promise<string[]> {
172
- let raw: RawRepo[];
173
- try {
174
- raw = await paginate<RawRepo>(octokit, budget, "GET /orgs/{owner}/repos", { owner, type: "all" }, maxRepos);
175
- } catch (err) {
176
- if (err instanceof RateLimited || err instanceof CallBudgetExhausted) throw err;
177
- if ((err as { status?: number }).status !== 404) throw err;
178
- raw = await paginate<RawRepo>(octokit, budget, "GET /users/{username}/repos", { username: owner, type: "all" }, maxRepos);
179
- }
180
- return raw.filter((r) => !r.archived).map((r) => r.name);
181
- }
@@ -1,138 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/review_thread.ts
5
- * @Date: 2026-07-11T10:24:46-07:00 (1783790686)
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
- import type { Octokit } from "@octokit/core";
17
-
18
- /**
19
- * Custom verbs: reply_review_thread + resolve_review_thread — respond to and resolve
20
- * pull-request review threads (e.g. Copilot's review comments).
21
- *
22
- * Why custom verbs:
23
- * - The hosted GitHub MCP can READ review threads (`pull_request_read` →
24
- * `get_review_comments`, which returns each thread's node id + `isResolved`), but
25
- * exposes NO tool to reply inside a thread or to resolve/unresolve one. Those are
26
- * GraphQL mutations (`addPullRequestReviewThreadReply`, `resolveReviewThread`,
27
- * `unresolveReviewThread`) with no hosted-MCP wrapper, so they can't be proxied.
28
- * - Unlike `link_issue`'s App-token-only mutation, these ARE available to a
29
- * fine-grained PAT with **Pull requests: write** — the same permission the broker's
30
- * write cred already uses for `create_pull_request` / `update_pull_request` /
31
- * `add_issue_comment`. So they run on the resolved write PAT (least privilege), not
32
- * the App token.
33
- *
34
- * Uses Octokit (`octokit.graphql`) — GitHub's official SDK.
35
- *
36
- * The `threadId` is the review-thread NODE id (e.g. "PRRT_kwDO…"), taken verbatim from
37
- * `pull_request_read` (`get_review_comments`) — each returned thread carries it as `id`.
38
- */
39
-
40
- export interface ResolveReviewThreadInput {
41
- owner: string;
42
- repo?: string;
43
- /** Review-thread node id (e.g. "PRRT_…") from pull_request_read get_review_comments. */
44
- threadId: string;
45
- /** true → resolve (default); false → unresolve. */
46
- resolve?: boolean;
47
- }
48
-
49
- export interface ReplyReviewThreadInput {
50
- owner: string;
51
- repo?: string;
52
- /** Review-thread node id (e.g. "PRRT_…") to reply into. */
53
- threadId: string;
54
- /** Reply body (Markdown). */
55
- body: string;
56
- }
57
-
58
- const RESOLVE_THREAD = `
59
- mutation($threadId: ID!) {
60
- resolveReviewThread(input: { threadId: $threadId }) {
61
- thread { id isResolved }
62
- }
63
- }`;
64
-
65
- const UNRESOLVE_THREAD = `
66
- mutation($threadId: ID!) {
67
- unresolveReviewThread(input: { threadId: $threadId }) {
68
- thread { id isResolved }
69
- }
70
- }`;
71
-
72
- const REPLY_THREAD = `
73
- mutation($threadId: ID!, $body: String!) {
74
- addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) {
75
- comment { id url }
76
- }
77
- }`;
78
-
79
- /** Resolve (or unresolve) a review thread. Returns the thread id and its new resolved state. */
80
- export async function resolveReviewThread(
81
- octokit: Octokit,
82
- input: ResolveReviewThreadInput
83
- ): Promise<{ threadId: string; isResolved: boolean }> {
84
- const unresolve = input.resolve === false;
85
- const data = await octokit.graphql<{
86
- resolveReviewThread?: { thread: { id: string; isResolved: boolean } };
87
- unresolveReviewThread?: { thread: { id: string; isResolved: boolean } };
88
- }>(unresolve ? UNRESOLVE_THREAD : RESOLVE_THREAD, { threadId: input.threadId });
89
- const thread = (data.resolveReviewThread ?? data.unresolveReviewThread)?.thread;
90
- if (!thread) throw new Error(`resolve_review_thread: no thread returned for "${input.threadId}"`);
91
- return { threadId: thread.id, isResolved: thread.isResolved };
92
- }
93
-
94
- /** Post a reply inside a review thread. Returns the new comment's node id + html url. */
95
- export async function replyReviewThread(
96
- octokit: Octokit,
97
- input: ReplyReviewThreadInput
98
- ): Promise<{ commentId: string; url: string }> {
99
- if (!input.body || !input.body.trim()) throw new Error("reply_review_thread: body is required");
100
- const data = await octokit.graphql<{
101
- addPullRequestReviewThreadReply: { comment: { id: string; url: string } };
102
- }>(REPLY_THREAD, { threadId: input.threadId, body: input.body });
103
- const comment = data.addPullRequestReviewThreadReply?.comment;
104
- if (!comment) throw new Error(`reply_review_thread: reply failed for "${input.threadId}"`);
105
- return { commentId: comment.id, url: comment.url };
106
- }
107
-
108
- /** JSON Schema advertised to the agent for resolve_review_thread. */
109
- export const RESOLVE_REVIEW_THREAD_SCHEMA = {
110
- type: "object" as const,
111
- required: ["owner", "threadId"],
112
- additionalProperties: false,
113
- properties: {
114
- owner: { type: "string", description: "repository owner (org/user login) — selects the write credential" },
115
- repo: { type: "string", description: "repository name (optional; for the audit log only)" },
116
- threadId: {
117
- type: "string",
118
- description: 'review-thread node id (e.g. "PRRT_…"), taken verbatim from pull_request_read get_review_comments'
119
- },
120
- resolve: { type: "boolean", description: "true to resolve (default), false to unresolve", default: true }
121
- }
122
- };
123
-
124
- /** JSON Schema advertised to the agent for reply_review_thread. */
125
- export const REPLY_REVIEW_THREAD_SCHEMA = {
126
- type: "object" as const,
127
- required: ["owner", "threadId", "body"],
128
- additionalProperties: false,
129
- properties: {
130
- owner: { type: "string", description: "repository owner (org/user login) — selects the write credential" },
131
- repo: { type: "string", description: "repository name (optional; for the audit log only)" },
132
- threadId: {
133
- type: "string",
134
- description: 'review-thread node id (e.g. "PRRT_…") to reply into, from pull_request_read get_review_comments'
135
- },
136
- body: { type: "string", description: "reply body (Markdown)" }
137
- }
138
- };
@@ -1,76 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- const { createAppAuthMock, octokitCtor } = vi.hoisted(() => ({
4
- createAppAuthMock: vi.fn(),
5
- // Constructable mock (arrow fns can't be `new`-ed).
6
- octokitCtor: vi.fn(function (this: any, opts: unknown) {
7
- this.__opts = opts;
8
- })
9
- }));
10
-
11
- vi.mock("@octokit/core", () => ({ Octokit: octokitCtor }));
12
- vi.mock("@octokit/auth-app", () => ({ createAppAuth: createAppAuthMock }));
13
-
14
- import { Octokit } from "@octokit/core";
15
- import { createAppAuth } from "@octokit/auth-app";
16
- import { AppAuth } from "../src/appauth.ts";
17
-
18
- function config(overrides: Record<string, unknown> = {}): any {
19
- return {
20
- listen: "unix:/x",
21
- upstream: "https://x",
22
- tools: [],
23
- app: { appId: 555, privateKey: "cred:app-key" },
24
- owners: {
25
- cldmv: { read: "cred:r", write: "cred:w", installationId: 42 },
26
- noapp: { read: "cred:r2", write: "cred:w2" }
27
- },
28
- ...overrides
29
- };
30
- }
31
-
32
- describe("AppAuth", () => {
33
- let secrets: { resolve: ReturnType<typeof vi.fn> };
34
-
35
- beforeEach(() => {
36
- vi.clearAllMocks();
37
- secrets = { resolve: vi.fn((ref: string) => (ref === "cred:app-key" ? "PEM-PRIVATE-KEY" : "other")) };
38
- });
39
-
40
- it("throws when the config has no app section", () => {
41
- expect(() => new AppAuth(config({ app: undefined }), secrets as any)).toThrow(/no app config/);
42
- });
43
-
44
- it("resolves the App private key at construction", () => {
45
- new AppAuth(config(), secrets as any);
46
- expect(secrets.resolve).toHaveBeenCalledWith("cred:app-key");
47
- });
48
-
49
- it("returns null for an owner without an installationId", () => {
50
- const auth = new AppAuth(config(), secrets as any);
51
- expect(auth.octokitFor("noapp")).toBeNull();
52
- });
53
-
54
- it("returns null for an unknown owner", () => {
55
- const auth = new AppAuth(config(), secrets as any);
56
- expect(auth.octokitFor("nobody")).toBeNull();
57
- });
58
-
59
- it("builds an App-authed Octokit for an owner with an installationId", () => {
60
- const auth = new AppAuth(config(), secrets as any);
61
- const ok = auth.octokitFor("cldmv");
62
- expect(ok).not.toBeNull();
63
- expect(Octokit).toHaveBeenCalledWith({
64
- authStrategy: createAppAuth,
65
- auth: { appId: 555, privateKey: "PEM-PRIVATE-KEY", installationId: 42 }
66
- });
67
- });
68
-
69
- it("caches the Octokit per installation id", () => {
70
- const auth = new AppAuth(config(), secrets as any);
71
- const first = auth.octokitFor("cldmv");
72
- const second = auth.octokitFor("cldmv");
73
- expect(first).toBe(second);
74
- expect(Octokit).toHaveBeenCalledTimes(1);
75
- });
76
- });
@@ -1,48 +0,0 @@
1
- import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { audit } from "../src/audit.ts";
3
-
4
- describe("audit", () => {
5
- afterEach(() => {
6
- vi.restoreAllMocks();
7
- });
8
-
9
- it("emits a single JSON line with a timestamp and the entry fields", () => {
10
- const spy = vi.spyOn(console, "log").mockImplementation(() => {});
11
- audit({ tool: "get_me", owner: "cldmv", mode: "read", repo: "cldmv/gh-broker", decision: "forward", ok: true });
12
- expect(spy).toHaveBeenCalledTimes(1);
13
- const line = spy.mock.calls[0]![0] as string;
14
- const parsed = JSON.parse(line);
15
- expect(parsed).toMatchObject({
16
- tool: "get_me",
17
- owner: "cldmv",
18
- mode: "read",
19
- repo: "cldmv/gh-broker",
20
- decision: "forward",
21
- ok: true
22
- });
23
- expect(typeof parsed.ts).toBe("string");
24
- expect(new Date(parsed.ts).toISOString()).toBe(parsed.ts); // valid ISO timestamp
25
- });
26
-
27
- it("never emits a token — no cred value or Authorization content in the line", () => {
28
- const spy = vi.spyOn(console, "log").mockImplementation(() => {});
29
- // The audit API has no token field; even if a caller mistakenly passed extra
30
- // fields, only the AuditEntry keys are what the router hands it. Assert the
31
- // emitted line carries no token-shaped content for the fields it does take.
32
- audit({ tool: "create_pull_request", owner: "cldmv", mode: "write", repo: "cldmv/x", decision: "custom", ok: true, detail: "done" });
33
- const line = spy.mock.calls[0]![0] as string;
34
- expect(line).not.toMatch(/token/i);
35
- expect(line).not.toMatch(/bearer/i);
36
- expect(line).not.toMatch(/cred:/);
37
- const parsed = JSON.parse(line);
38
- expect(Object.keys(parsed).sort()).toEqual(["decision", "detail", "mode", "ok", "owner", "repo", "tool", "ts"].sort());
39
- });
40
-
41
- it("emits a minimal deny entry", () => {
42
- const spy = vi.spyOn(console, "log").mockImplementation(() => {});
43
- audit({ tool: "rm_rf", decision: "deny", detail: "not in allowlist" });
44
- const parsed = JSON.parse(spy.mock.calls[0]![0] as string);
45
- expect(parsed).toMatchObject({ tool: "rm_rf", decision: "deny", detail: "not in allowlist" });
46
- expect(parsed.owner).toBeUndefined();
47
- });
48
- });
@@ -1,180 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { buildCatalog } from "../src/catalog.ts";
3
-
4
- function makeConfig(tools: any[], owners: Record<string, unknown> = { cldmv: { read: "cred:r", write: "cred:w" } }): any {
5
- return { listen: "unix:/x", upstream: "https://api.githubcopilot.com/mcp/", tools, owners };
6
- }
7
-
8
- describe("buildCatalog", () => {
9
- let secrets: { resolve: ReturnType<typeof vi.fn> };
10
- let upstream: { listToolsFor: ReturnType<typeof vi.fn> };
11
- let errSpy: ReturnType<typeof vi.spyOn>;
12
-
13
- beforeEach(() => {
14
- secrets = { resolve: vi.fn(() => "tok") };
15
- upstream = { listToolsFor: vi.fn() };
16
- errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
17
- });
18
-
19
- afterEach(() => {
20
- vi.restoreAllMocks();
21
- delete process.env.GH_BROKER_CATALOG_TIMEOUT_MS;
22
- });
23
-
24
- it("uses upstream schemas, emits local schemas for custom verbs, and skips unexposed proxied tools", async () => {
25
- const upstreamTool = { name: "get_me", description: "real", inputSchema: { type: "object", properties: {} } };
26
- upstream.listToolsFor.mockResolvedValue([upstreamTool]);
27
- const config = makeConfig([
28
- { name: "get_me", mode: "read" },
29
- { name: "not_exposed", mode: "read" },
30
- { name: "link_issue", mode: "write", custom: true },
31
- { name: "resolve_review_thread", mode: "write", custom: true },
32
- { name: "reply_review_thread", mode: "write", custom: true },
33
- { name: "mystery_custom", mode: "read", custom: true }
34
- ]);
35
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
36
-
37
- expect(secrets.resolve).toHaveBeenCalledWith("cred:r");
38
- const names = catalog.map((t) => t.name);
39
- // get_me is served from upstream; not_exposed is dropped (fetched but absent);
40
- // the two wired custom verbs get local schemas; link_issue is DEFERRED (its
41
- // catalog branch is commented out) and mystery_custom matches no branch — both
42
- // are silently skipped even when allowlisted.
43
- expect(names).toEqual(["get_me", "resolve_review_thread", "reply_review_thread"]);
44
- expect(names).not.toContain("link_issue");
45
- expect(catalog[0]).toBe(upstreamTool);
46
- expect(catalog.find((t) => t.name === "resolve_review_thread")?.inputSchema).toMatchObject({ type: "object" });
47
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/allowlisted tool "not_exposed" is NOT exposed/));
48
- });
49
-
50
- it("advertises report_unlabeled with its local schema", async () => {
51
- upstream.listToolsFor.mockResolvedValue([]);
52
- const config = makeConfig([{ name: "report_unlabeled", mode: "read", custom: true }]);
53
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
54
- expect(catalog).toHaveLength(1);
55
- expect(catalog[0]).toMatchObject({ name: "report_unlabeled" });
56
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
57
- });
58
-
59
- it("advertises report_security_alerts with its local schema", async () => {
60
- upstream.listToolsFor.mockResolvedValue([]);
61
- const config = makeConfig([{ name: "report_security_alerts", mode: "read", custom: true }]);
62
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
63
- expect(catalog).toHaveLength(1);
64
- expect(catalog[0]).toMatchObject({ name: "report_security_alerts" });
65
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
66
- });
67
-
68
- it("advertises report_release_prs with its local schema", async () => {
69
- upstream.listToolsFor.mockResolvedValue([]);
70
- const config = makeConfig([{ name: "report_release_prs", mode: "read", custom: true }]);
71
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
72
- expect(catalog).toHaveLength(1);
73
- expect(catalog[0]).toMatchObject({ name: "report_release_prs" });
74
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
75
- });
76
-
77
- it("advertises issue_labels with its local schema", async () => {
78
- upstream.listToolsFor.mockResolvedValue([]);
79
- const config = makeConfig([{ name: "issue_labels", mode: "write", custom: true }]);
80
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
81
- expect(catalog).toHaveLength(1);
82
- expect(catalog[0]).toMatchObject({ name: "issue_labels" });
83
- expect(catalog[0]?.description).toMatch(/swap/);
84
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "issue_number"] });
85
- });
86
-
87
- it("warns on issue_write that labels replaces the set, pointing at issue_labels when it is allowlisted", async () => {
88
- const upstreamTool = { name: "issue_write", description: "Create or update an issue.", inputSchema: { type: "object", properties: { labels: {} } } };
89
- upstream.listToolsFor.mockResolvedValue([upstreamTool]);
90
- const withVerb = await buildCatalog(
91
- makeConfig([{ name: "issue_write", mode: "write" }, { name: "issue_labels", mode: "write", custom: true }]),
92
- secrets as any,
93
- upstream as any
94
- );
95
- const iw = withVerb.find((t) => t.name === "issue_write")!;
96
- expect(iw.description).toMatch(/^Create or update an issue\. WARNING \(gh-broker\): "labels" REPLACES the issue's whole label set/);
97
- expect(iw.description).toMatch(/use issue_labels instead/);
98
- expect(iw.inputSchema).toBe(upstreamTool.inputSchema);
99
- expect(upstreamTool.description).toBe("Create or update an issue."); // the fetched tool is not mutated
100
-
101
- const without = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
102
- expect(without[0]?.description).toMatch(/REPLACES/);
103
- expect(without[0]?.description).not.toMatch(/issue_labels/);
104
- expect(without[0]?.description).toMatch(/pass the full set/);
105
- });
106
-
107
- it("still warns on issue_write when the upstream fetch fails (minimal schema)", async () => {
108
- upstream.listToolsFor.mockRejectedValue(new Error("down"));
109
- const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
110
- expect(catalog[0]?.description).toMatch(/^\(write\) proxied to the GitHub MCP WARNING/);
111
- });
112
-
113
- it("advertises report_ci_status with its local schema", async () => {
114
- upstream.listToolsFor.mockResolvedValue([]);
115
- const config = makeConfig([{ name: "report_ci_status", mode: "read", custom: true }]);
116
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
117
- expect(catalog).toHaveLength(1);
118
- expect(catalog[0]).toMatchObject({ name: "report_ci_status" });
119
- expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo"] });
120
- });
121
-
122
- it("layers ciVerbosity/logLines onto pull_request_read's real upstream schema without dropping its own params", async () => {
123
- const upstreamTool = {
124
- name: "pull_request_read",
125
- description: "Get information on a specific pull request.",
126
- inputSchema: { type: "object", required: ["owner", "repo", "pullNumber", "method"], properties: { method: { type: "string" }, pullNumber: { type: "number" } } }
127
- };
128
- upstream.listToolsFor.mockResolvedValue([upstreamTool]);
129
- const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
130
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
131
- expect(catalog).toHaveLength(1);
132
- const tool = catalog[0]!;
133
- expect(tool.description).toContain("gh-broker extension");
134
- expect((tool.inputSchema as any).required).toEqual(["owner", "repo", "pullNumber", "method"]);
135
- expect((tool.inputSchema as any).properties.pullNumber).toEqual({ type: "number" });
136
- expect((tool.inputSchema as any).properties.ciVerbosity).toMatchObject({ type: "number", enum: [0, 1, 2, 3] });
137
- expect((tool.inputSchema as any).properties.logLines).toMatchObject({ type: "number" });
138
- });
139
-
140
- it("still adds ciVerbosity/logLines to pull_request_read when the upstream fetch fails entirely", async () => {
141
- upstream.listToolsFor.mockRejectedValue(new Error("network down"));
142
- const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
143
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
144
- expect(catalog).toHaveLength(1);
145
- expect((catalog[0]!.inputSchema as any).properties).toEqual({
146
- ciVerbosity: { type: "number", enum: [0, 1, 2, 3], description: expect.any(String) },
147
- logLines: { type: "number", description: expect.any(String) }
148
- });
149
- });
150
-
151
- it("falls back to minimal schemas when the upstream fetch fails", async () => {
152
- upstream.listToolsFor.mockRejectedValue(new Error("401 bad token"));
153
- const config = makeConfig([
154
- { name: "get_me", mode: "read" },
155
- { name: "resolve_review_thread", mode: "write", custom: true }
156
- ]);
157
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
158
-
159
- const getMe = catalog.find((t) => t.name === "get_me");
160
- expect(getMe).toEqual({ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } });
161
- expect(catalog.map((t) => t.name)).toContain("resolve_review_thread");
162
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/could not fetch upstream tool schemas/));
163
- });
164
-
165
- it("skips the upstream fetch entirely when there are no owners", async () => {
166
- const config = makeConfig([{ name: "get_me", mode: "read" }], {});
167
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
168
- expect(upstream.listToolsFor).not.toHaveBeenCalled();
169
- expect(catalog).toEqual([{ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } }]);
170
- });
171
-
172
- it("times out a slow upstream and advertises minimal schemas", async () => {
173
- process.env.GH_BROKER_CATALOG_TIMEOUT_MS = "15";
174
- upstream.listToolsFor.mockReturnValue(new Promise(() => {})); // never resolves
175
- const config = makeConfig([{ name: "get_me", mode: "read" }]);
176
- const catalog = await buildCatalog(config, secrets as any, upstream as any);
177
- expect(catalog.map((t) => t.name)).toEqual(["get_me"]);
178
- expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 15ms/));
179
- });
180
- });