@cldmv/gh-broker 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +40 -10
  2. package/config.example.json +17 -1
  3. package/deploy/README.md +3 -2
  4. package/deploy/install.mjs +5 -3
  5. package/dist/index.mjs +4261 -1642
  6. package/dist/index.mjs.map +4 -4
  7. package/package.json +12 -2
  8. package/docs/TOOLS.md +0 -538
  9. package/docs/agent-user-model.md +0 -89
  10. package/docs/changelog/v0/v0.2.0.md +0 -26
  11. package/docs/changelog/v0/v0.2.1.md +0 -23
  12. package/docs/changelog/v0/v0.2.2.md +0 -23
  13. package/docs/changelog/v0/v0.2.3.md +0 -33
  14. package/docs/changelog/v0/v0.2.4.md +0 -25
  15. package/docs/changelog/v0/v0.2.5.md +0 -29
  16. package/docs/changelog/v0/v0.3.0.md +0 -84
  17. package/docs/changelog/v0/v0.3.1.md +0 -30
  18. package/docs/changelog/v0/v0.3.2.md +0 -24
  19. package/docs/changelog/v0/v0.3.3.md +0 -28
  20. package/docs/changelog/v0/v0.4.0.md +0 -58
  21. package/docs/changelog/v0/v0.4.1.md +0 -33
  22. package/docs/changelog/v0/v0.4.2.md +0 -45
  23. package/docs/changelog/v0/v0.5.0.md +0 -39
  24. package/docs/changelog/v0/v0.5.1.md +0 -28
  25. package/docs/changelog/v0/v0.6.0.md +0 -52
  26. package/docs/changelog/v0/v0.6.1.md +0 -48
  27. package/docs/changelog/v0/v0.7.0.md +0 -71
  28. package/docs/changelog/v0/v0.8.0.md +0 -115
  29. package/docs/deploy-architecture.md +0 -84
  30. package/src/appauth.ts +0 -56
  31. package/src/audit.ts +0 -34
  32. package/src/availability.ts +0 -42
  33. package/src/catalog.ts +0 -182
  34. package/src/config.ts +0 -157
  35. package/src/index.ts +0 -107
  36. package/src/paths.ts +0 -60
  37. package/src/router.ts +0 -299
  38. package/src/secrets.ts +0 -167
  39. package/src/server.ts +0 -91
  40. package/src/types.ts +0 -128
  41. package/src/upstream.ts +0 -85
  42. package/src/verbs/ghcr/archive.ts +0 -314
  43. package/src/verbs/ghcr/config.ts +0 -142
  44. package/src/verbs/ghcr/jobs.ts +0 -141
  45. package/src/verbs/ghcr/push.ts +0 -192
  46. package/src/verbs/ghcr/registry.ts +0 -275
  47. package/src/verbs/ghcr/tar.ts +0 -203
  48. package/src/verbs/ghcr_push.ts +0 -264
  49. package/src/verbs/issue_labels.ts +0 -329
  50. package/src/verbs/link_issue.ts +0 -84
  51. package/src/verbs/npm-registry.ts +0 -168
  52. package/src/verbs/npm-tarball.ts +0 -76
  53. package/src/verbs/npm_scaffold.ts +0 -325
  54. package/src/verbs/release-analysis.ts +0 -212
  55. package/src/verbs/repo-tier.ts +0 -90
  56. package/src/verbs/report_ci_status.ts +0 -358
  57. package/src/verbs/report_release_prs.ts +0 -238
  58. package/src/verbs/report_security_alerts.ts +0 -442
  59. package/src/verbs/report_unlabeled.ts +0 -194
  60. package/src/verbs/rest-client.ts +0 -181
  61. package/src/verbs/review_thread.ts +0 -138
  62. package/tests/appauth.test.vitest.mts +0 -76
  63. package/tests/audit.test.vitest.mts +0 -48
  64. package/tests/catalog.test.vitest.mts +0 -234
  65. package/tests/config-merge.test.vitest.mts +0 -325
  66. package/tests/config.test.vitest.mts +0 -388
  67. package/tests/credentials.test.vitest.mts +0 -127
  68. package/tests/ghcr-archive.test.vitest.mts +0 -439
  69. package/tests/ghcr-config.test.vitest.mts +0 -96
  70. package/tests/ghcr-push.test.vitest.mts +0 -526
  71. package/tests/ghcr-registry.test.vitest.mts +0 -206
  72. package/tests/helpers/ghcr-fixtures.mts +0 -373
  73. package/tests/install-args.test.vitest.mts +0 -152
  74. package/tests/issue-labels.test.vitest.mts +0 -362
  75. package/tests/npm-scaffold.test.vitest.mts +0 -479
  76. package/tests/npm-setup.test.vitest.mts +0 -196
  77. package/tests/paths.test.vitest.mts +0 -57
  78. package/tests/prompt.test.vitest.mts +0 -128
  79. package/tests/release-prs.test.vitest.mts +0 -247
  80. package/tests/rest-client.test.vitest.mts +0 -115
  81. package/tests/router.test.vitest.mts +0 -505
  82. package/tests/run-vitest.mjs +0 -51
  83. package/tests/secret-store.test.vitest.mts +0 -167
  84. package/tests/secrets.test.vitest.mts +0 -208
  85. package/tests/security-alerts.test.vitest.mts +0 -338
  86. package/tests/server.test.vitest.mts +0 -200
  87. package/tests/smoke.test.vitest.mts +0 -177
  88. package/tests/system.test.vitest.mts +0 -355
  89. package/tests/upstream.test.vitest.mts +0 -91
  90. package/tests/verb-helpers.test.vitest.mts +0 -377
  91. package/tests/verbs.test.vitest.mts +0 -624
  92. package/tsconfig.json +0 -21
@@ -1,194 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/report_unlabeled.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
- import { CallBudget, CallBudgetExhausted, RateLimited, listOwnerRepos, paginate } from "./rest-client.ts";
18
-
19
- /**
20
- * Custom verb: report_unlabeled — issues/PRs with no labels, for one repo or every
21
- * repo an owner has.
22
- *
23
- * Why a custom verb: the hosted GitHub MCP has no "no labels" filter and no
24
- * cross-repo aggregation — `list_issues`/`list_pull_requests` are single-repo, and
25
- * finding the unlabeled subset means paging through everything and filtering
26
- * client-side. This composes plain REST reads (`octokit.request`) over the read PAT,
27
- * the same allowlist tier as everything it uses.
28
- *
29
- * Fan-out safety (see rest-client.ts for the shared rate-limit/budget mechanics):
30
- * scanning "every repo" can mean dozens of repos x hundreds of issues/PRs each.
31
- * - `maxRepos` caps how many repos get enumerated when `repo` is omitted.
32
- * - `perRepoLimit` caps items fetched per repo, per resource type (issues, pulls).
33
- * - `maxApiCalls` is a hard ceiling on total REST calls made by one invocation.
34
- * Hitting any cap — or GitHub's own rate limit — stops the scan and returns whatever
35
- * was found so far with `truncated: true`, rather than throwing partial work away.
36
- */
37
-
38
- export type Target = "issues" | "pulls" | "both";
39
- export type State = "open" | "closed" | "all";
40
-
41
- export interface ReportUnlabeledInput {
42
- owner: string;
43
- /** Repo name; omit to scan every non-archived repo visible to this owner's read PAT. */
44
- repo?: string;
45
- target?: Target;
46
- state?: State;
47
- /** Include draft PRs in the report (default: excluded — a draft isn't triage-ready). */
48
- includeDrafts?: boolean;
49
- /** Safety cap on repos scanned when `repo` is omitted. Default 50, hard-capped at 200. */
50
- maxRepos?: number;
51
- /** Safety cap on items fetched per repo, per resource type. Default 200, hard-capped at 1000. */
52
- perRepoLimit?: number;
53
- /** Hard ceiling on total REST calls for this invocation. Default 250, hard-capped at 1000. */
54
- maxApiCalls?: number;
55
- }
56
-
57
- export interface UnlabeledItem {
58
- repo: string;
59
- type: "issue" | "pull";
60
- number: number;
61
- title: string;
62
- url: string;
63
- createdAt: string;
64
- }
65
-
66
- export type TruncatedReason = "max-repos" | "max-api-calls" | "rate-limited";
67
-
68
- export interface ReportUnlabeledResult {
69
- scanned: { repos: number; issues: number; pulls: number };
70
- unlabeled: UnlabeledItem[];
71
- truncated?: boolean;
72
- truncatedReason?: TruncatedReason;
73
- /** Only set when truncatedReason is "rate-limited" and GitHub sent a Retry-After. */
74
- retryAfterSeconds?: number;
75
- }
76
-
77
- const DEFAULT_MAX_REPOS = 50;
78
- const HARD_MAX_REPOS = 200;
79
- const DEFAULT_PER_REPO_LIMIT = 200;
80
- const HARD_PER_REPO_LIMIT = 1000;
81
- const DEFAULT_MAX_API_CALLS = 250;
82
- const HARD_MAX_API_CALLS = 1000;
83
-
84
- interface RawIssueOrPull {
85
- number: number;
86
- title: string;
87
- html_url: string;
88
- created_at: string;
89
- labels: unknown[];
90
- /** Present only on items returned by the /issues endpoint that are actually PRs. */
91
- pull_request?: unknown;
92
- /** Present only on items returned by the /pulls endpoint. */
93
- draft?: boolean;
94
- }
95
-
96
- async function listUnlabeledIssues(
97
- octokit: Octokit,
98
- budget: CallBudget,
99
- owner: string,
100
- repo: string,
101
- state: State,
102
- limit: number
103
- ): Promise<{ scanned: number; unlabeled: UnlabeledItem[] }> {
104
- const raw = await paginate<RawIssueOrPull>(octokit, budget, "GET /repos/{owner}/{repo}/issues", { owner, repo, state }, limit);
105
- const issuesOnly = raw.filter((it) => !("pull_request" in it));
106
- const unlabeled = issuesOnly
107
- .filter((it) => it.labels.length === 0)
108
- .map((it) => toUnlabeledItem(owner, repo, "issue", it));
109
- return { scanned: issuesOnly.length, unlabeled };
110
- }
111
-
112
- async function listUnlabeledPulls(
113
- octokit: Octokit,
114
- budget: CallBudget,
115
- owner: string,
116
- repo: string,
117
- state: State,
118
- limit: number,
119
- includeDrafts: boolean
120
- ): Promise<{ scanned: number; unlabeled: UnlabeledItem[] }> {
121
- const raw = await paginate<RawIssueOrPull>(octokit, budget, "GET /repos/{owner}/{repo}/pulls", { owner, repo, state }, limit);
122
- const unlabeled = raw
123
- .filter((it) => includeDrafts || !it.draft)
124
- .filter((it) => it.labels.length === 0)
125
- .map((it) => toUnlabeledItem(owner, repo, "pull", it));
126
- return { scanned: raw.length, unlabeled };
127
- }
128
-
129
- function toUnlabeledItem(owner: string, repo: string, type: "issue" | "pull", it: RawIssueOrPull): UnlabeledItem {
130
- return { repo: `${owner}/${repo}`, type, number: it.number, title: it.title, url: it.html_url, createdAt: it.created_at };
131
- }
132
-
133
- export async function reportUnlabeled(octokit: Octokit, input: ReportUnlabeledInput): Promise<ReportUnlabeledResult> {
134
- const target = input.target ?? "both";
135
- const state = input.state ?? "open";
136
- const includeDrafts = input.includeDrafts ?? false;
137
- const maxRepos = Math.min(input.maxRepos ?? DEFAULT_MAX_REPOS, HARD_MAX_REPOS);
138
- const perRepoLimit = Math.min(input.perRepoLimit ?? DEFAULT_PER_REPO_LIMIT, HARD_PER_REPO_LIMIT);
139
- const maxApiCalls = Math.min(input.maxApiCalls ?? DEFAULT_MAX_API_CALLS, HARD_MAX_API_CALLS);
140
- const budget = new CallBudget(maxApiCalls);
141
-
142
- const unlabeled: UnlabeledItem[] = [];
143
- const scanned = { repos: 0, issues: 0, pulls: 0 };
144
-
145
- try {
146
- let repos: string[];
147
- if (input.repo) {
148
- repos = [input.repo];
149
- } else {
150
- repos = await listOwnerRepos(octokit, budget, input.owner, maxRepos);
151
- }
152
- scanned.repos = repos.length;
153
-
154
- for (const repo of repos) {
155
- if (target === "issues" || target === "both") {
156
- const out = await listUnlabeledIssues(octokit, budget, input.owner, repo, state, perRepoLimit);
157
- scanned.issues += out.scanned;
158
- unlabeled.push(...out.unlabeled);
159
- }
160
- if (target === "pulls" || target === "both") {
161
- const out = await listUnlabeledPulls(octokit, budget, input.owner, repo, state, perRepoLimit, includeDrafts);
162
- scanned.pulls += out.scanned;
163
- unlabeled.push(...out.unlabeled);
164
- }
165
- }
166
- } catch (err) {
167
- if (err instanceof RateLimited) {
168
- return { scanned, unlabeled, truncated: true, truncatedReason: "rate-limited", ...(err.retryAfterSeconds !== undefined ? { retryAfterSeconds: err.retryAfterSeconds } : {}) };
169
- }
170
- if (err instanceof CallBudgetExhausted) {
171
- return { scanned, unlabeled, truncated: true, truncatedReason: "max-api-calls" };
172
- }
173
- throw err;
174
- }
175
-
176
- return { scanned, unlabeled };
177
- }
178
-
179
- /** JSON Schema advertised to the agent for report_unlabeled. */
180
- export const REPORT_UNLABELED_SCHEMA = {
181
- type: "object" as const,
182
- required: ["owner"],
183
- additionalProperties: false,
184
- properties: {
185
- owner: { type: "string", description: "repository owner (org/user login) — selects the read credential" },
186
- repo: { type: "string", description: "repository name; omit to scan every non-archived repo visible to this owner's read PAT" },
187
- target: { type: "string", enum: ["issues", "pulls", "both"], description: "what to scan for missing labels", default: "both" },
188
- state: { type: "string", enum: ["open", "closed", "all"], description: "issue/PR state to scan", default: "open" },
189
- includeDrafts: { type: "boolean", description: "include draft PRs in the report (default: excluded)", default: false },
190
- maxRepos: { type: "number", description: `safety cap on repos scanned when repo is omitted (default ${DEFAULT_MAX_REPOS}, hard cap ${HARD_MAX_REPOS})` },
191
- perRepoLimit: { type: "number", description: `safety cap on items scanned per repo, per resource type (default ${DEFAULT_PER_REPO_LIMIT}, hard cap ${HARD_PER_REPO_LIMIT})` },
192
- maxApiCalls: { type: "number", description: `hard ceiling on total REST calls this invocation makes (default ${DEFAULT_MAX_API_CALLS}, hard cap ${HARD_MAX_API_CALLS})` }
193
- }
194
- };
@@ -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
- });