@cldmv/gh-broker 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. package/tsconfig.json +0 -21
@@ -1,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
- });