@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.
- package/README.md +48 -13
- package/config.example.json +86 -3
- package/config.sample.json +1 -1
- package/deploy/README.md +58 -18
- package/deploy/cli.mjs +38 -0
- package/deploy/gh-broker.service +9 -2
- package/deploy/install.mjs +100 -41
- package/deploy/lib/agent-cli.mjs +166 -0
- package/deploy/lib/agents.mjs +128 -0
- package/deploy/lib/cli.mjs +72 -0
- package/deploy/lib/config-merge.mjs +29 -8
- package/deploy/lib/install-args.mjs +3 -2
- package/deploy/lib/mcp-config.mjs +211 -0
- package/deploy/lib/otp-cli.mjs +136 -0
- package/deploy/lib/otp.mjs +325 -0
- package/deploy/lib/secret-store.mjs +1 -0
- package/deploy/lib/system.mjs +24 -12
- package/deploy/real-deps.mjs +102 -0
- package/dist/index.mjs +5171 -1686
- package/dist/index.mjs.map +4 -4
- package/package.json +14 -3
- package/docs/TOOLS.md +0 -538
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- 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
|
-
};
|
package/src/verbs/rest-client.ts
DELETED
|
@@ -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
|
-
});
|