@cldmv/gh-broker 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -41
- package/config.example.json +38 -9
- package/config.sample.json +45 -0
- package/deploy/README.md +76 -17
- package/deploy/install.mjs +171 -104
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +5864 -1809
- package/dist/index.mjs.map +4 -4
- package/package.json +18 -8
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
- package/docs/TOOLS.md +0 -347
- 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/deploy-architecture.md +0 -82
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/catalog.ts +0 -165
- package/src/config.ts +0 -130
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -258
- package/src/secrets.ts +0 -137
- package/src/server.ts +0 -91
- package/src/types.ts +0 -104
- package/src/upstream.ts +0 -85
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/release-analysis.ts +0 -210
- 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 -180
- package/tests/config-merge.test.vitest.mts +0 -58
- package/tests/config.test.vitest.mts +0 -314
- package/tests/issue-labels.test.vitest.mts +0 -347
- package/tests/paths.test.vitest.mts +0 -52
- package/tests/release-prs.test.vitest.mts +0 -237
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -399
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -106
- package/tests/secrets.test.vitest.mts +0 -155
- package/tests/security-alerts.test.vitest.mts +0 -328
- package/tests/server.test.vitest.mts +0 -20
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -331
- package/tests/verbs.test.vitest.mts +0 -624
- package/tsconfig.json +0 -21
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
|
-
});
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { buildCatalog } from "../src/catalog.ts";
|
|
3
|
-
|
|
4
|
-
function makeConfig(tools: any[], owners: Record<string, unknown> = { cldmv: { read: "cred:r", write: "cred:w" } }): any {
|
|
5
|
-
return { listen: "unix:/x", upstream: "https://api.githubcopilot.com/mcp/", tools, owners };
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
describe("buildCatalog", () => {
|
|
9
|
-
let secrets: { resolve: ReturnType<typeof vi.fn> };
|
|
10
|
-
let upstream: { listToolsFor: ReturnType<typeof vi.fn> };
|
|
11
|
-
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
secrets = { resolve: vi.fn(() => "tok") };
|
|
15
|
-
upstream = { listToolsFor: vi.fn() };
|
|
16
|
-
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
vi.restoreAllMocks();
|
|
21
|
-
delete process.env.GH_BROKER_CATALOG_TIMEOUT_MS;
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("uses upstream schemas, emits local schemas for custom verbs, and skips unexposed proxied tools", async () => {
|
|
25
|
-
const upstreamTool = { name: "get_me", description: "real", inputSchema: { type: "object", properties: {} } };
|
|
26
|
-
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
27
|
-
const config = makeConfig([
|
|
28
|
-
{ name: "get_me", mode: "read" },
|
|
29
|
-
{ name: "not_exposed", mode: "read" },
|
|
30
|
-
{ name: "link_issue", mode: "write", custom: true },
|
|
31
|
-
{ name: "resolve_review_thread", mode: "write", custom: true },
|
|
32
|
-
{ name: "reply_review_thread", mode: "write", custom: true },
|
|
33
|
-
{ name: "mystery_custom", mode: "read", custom: true }
|
|
34
|
-
]);
|
|
35
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
36
|
-
|
|
37
|
-
expect(secrets.resolve).toHaveBeenCalledWith("cred:r");
|
|
38
|
-
const names = catalog.map((t) => t.name);
|
|
39
|
-
// get_me is served from upstream; not_exposed is dropped (fetched but absent);
|
|
40
|
-
// the two wired custom verbs get local schemas; link_issue is DEFERRED (its
|
|
41
|
-
// catalog branch is commented out) and mystery_custom matches no branch — both
|
|
42
|
-
// are silently skipped even when allowlisted.
|
|
43
|
-
expect(names).toEqual(["get_me", "resolve_review_thread", "reply_review_thread"]);
|
|
44
|
-
expect(names).not.toContain("link_issue");
|
|
45
|
-
expect(catalog[0]).toBe(upstreamTool);
|
|
46
|
-
expect(catalog.find((t) => t.name === "resolve_review_thread")?.inputSchema).toMatchObject({ type: "object" });
|
|
47
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/allowlisted tool "not_exposed" is NOT exposed/));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("advertises report_unlabeled with its local schema", async () => {
|
|
51
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
52
|
-
const config = makeConfig([{ name: "report_unlabeled", mode: "read", custom: true }]);
|
|
53
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
54
|
-
expect(catalog).toHaveLength(1);
|
|
55
|
-
expect(catalog[0]).toMatchObject({ name: "report_unlabeled" });
|
|
56
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("advertises report_security_alerts with its local schema", async () => {
|
|
60
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
61
|
-
const config = makeConfig([{ name: "report_security_alerts", mode: "read", custom: true }]);
|
|
62
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
63
|
-
expect(catalog).toHaveLength(1);
|
|
64
|
-
expect(catalog[0]).toMatchObject({ name: "report_security_alerts" });
|
|
65
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("advertises report_release_prs with its local schema", async () => {
|
|
69
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
70
|
-
const config = makeConfig([{ name: "report_release_prs", mode: "read", custom: true }]);
|
|
71
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
72
|
-
expect(catalog).toHaveLength(1);
|
|
73
|
-
expect(catalog[0]).toMatchObject({ name: "report_release_prs" });
|
|
74
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner"] });
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("advertises issue_labels with its local schema", async () => {
|
|
78
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
79
|
-
const config = makeConfig([{ name: "issue_labels", mode: "write", custom: true }]);
|
|
80
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
81
|
-
expect(catalog).toHaveLength(1);
|
|
82
|
-
expect(catalog[0]).toMatchObject({ name: "issue_labels" });
|
|
83
|
-
expect(catalog[0]?.description).toMatch(/swap/);
|
|
84
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo", "issue_number"] });
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("warns on issue_write that labels replaces the set, pointing at issue_labels when it is allowlisted", async () => {
|
|
88
|
-
const upstreamTool = { name: "issue_write", description: "Create or update an issue.", inputSchema: { type: "object", properties: { labels: {} } } };
|
|
89
|
-
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
90
|
-
const withVerb = await buildCatalog(
|
|
91
|
-
makeConfig([{ name: "issue_write", mode: "write" }, { name: "issue_labels", mode: "write", custom: true }]),
|
|
92
|
-
secrets as any,
|
|
93
|
-
upstream as any
|
|
94
|
-
);
|
|
95
|
-
const iw = withVerb.find((t) => t.name === "issue_write")!;
|
|
96
|
-
expect(iw.description).toMatch(/^Create or update an issue\. WARNING \(gh-broker\): "labels" REPLACES the issue's whole label set/);
|
|
97
|
-
expect(iw.description).toMatch(/use issue_labels instead/);
|
|
98
|
-
expect(iw.inputSchema).toBe(upstreamTool.inputSchema);
|
|
99
|
-
expect(upstreamTool.description).toBe("Create or update an issue."); // the fetched tool is not mutated
|
|
100
|
-
|
|
101
|
-
const without = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
102
|
-
expect(without[0]?.description).toMatch(/REPLACES/);
|
|
103
|
-
expect(without[0]?.description).not.toMatch(/issue_labels/);
|
|
104
|
-
expect(without[0]?.description).toMatch(/pass the full set/);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("still warns on issue_write when the upstream fetch fails (minimal schema)", async () => {
|
|
108
|
-
upstream.listToolsFor.mockRejectedValue(new Error("down"));
|
|
109
|
-
const catalog = await buildCatalog(makeConfig([{ name: "issue_write", mode: "write" }]), secrets as any, upstream as any);
|
|
110
|
-
expect(catalog[0]?.description).toMatch(/^\(write\) proxied to the GitHub MCP WARNING/);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("advertises report_ci_status with its local schema", async () => {
|
|
114
|
-
upstream.listToolsFor.mockResolvedValue([]);
|
|
115
|
-
const config = makeConfig([{ name: "report_ci_status", mode: "read", custom: true }]);
|
|
116
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
117
|
-
expect(catalog).toHaveLength(1);
|
|
118
|
-
expect(catalog[0]).toMatchObject({ name: "report_ci_status" });
|
|
119
|
-
expect(catalog[0]?.inputSchema).toMatchObject({ type: "object", required: ["owner", "repo"] });
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("layers ciVerbosity/logLines onto pull_request_read's real upstream schema without dropping its own params", async () => {
|
|
123
|
-
const upstreamTool = {
|
|
124
|
-
name: "pull_request_read",
|
|
125
|
-
description: "Get information on a specific pull request.",
|
|
126
|
-
inputSchema: { type: "object", required: ["owner", "repo", "pullNumber", "method"], properties: { method: { type: "string" }, pullNumber: { type: "number" } } }
|
|
127
|
-
};
|
|
128
|
-
upstream.listToolsFor.mockResolvedValue([upstreamTool]);
|
|
129
|
-
const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
|
|
130
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
131
|
-
expect(catalog).toHaveLength(1);
|
|
132
|
-
const tool = catalog[0]!;
|
|
133
|
-
expect(tool.description).toContain("gh-broker extension");
|
|
134
|
-
expect((tool.inputSchema as any).required).toEqual(["owner", "repo", "pullNumber", "method"]);
|
|
135
|
-
expect((tool.inputSchema as any).properties.pullNumber).toEqual({ type: "number" });
|
|
136
|
-
expect((tool.inputSchema as any).properties.ciVerbosity).toMatchObject({ type: "number", enum: [0, 1, 2, 3] });
|
|
137
|
-
expect((tool.inputSchema as any).properties.logLines).toMatchObject({ type: "number" });
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("still adds ciVerbosity/logLines to pull_request_read when the upstream fetch fails entirely", async () => {
|
|
141
|
-
upstream.listToolsFor.mockRejectedValue(new Error("network down"));
|
|
142
|
-
const config = makeConfig([{ name: "pull_request_read", mode: "read", augment: true }]);
|
|
143
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
144
|
-
expect(catalog).toHaveLength(1);
|
|
145
|
-
expect((catalog[0]!.inputSchema as any).properties).toEqual({
|
|
146
|
-
ciVerbosity: { type: "number", enum: [0, 1, 2, 3], description: expect.any(String) },
|
|
147
|
-
logLines: { type: "number", description: expect.any(String) }
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("falls back to minimal schemas when the upstream fetch fails", async () => {
|
|
152
|
-
upstream.listToolsFor.mockRejectedValue(new Error("401 bad token"));
|
|
153
|
-
const config = makeConfig([
|
|
154
|
-
{ name: "get_me", mode: "read" },
|
|
155
|
-
{ name: "resolve_review_thread", mode: "write", custom: true }
|
|
156
|
-
]);
|
|
157
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
158
|
-
|
|
159
|
-
const getMe = catalog.find((t) => t.name === "get_me");
|
|
160
|
-
expect(getMe).toEqual({ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } });
|
|
161
|
-
expect(catalog.map((t) => t.name)).toContain("resolve_review_thread");
|
|
162
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/could not fetch upstream tool schemas/));
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("skips the upstream fetch entirely when there are no owners", async () => {
|
|
166
|
-
const config = makeConfig([{ name: "get_me", mode: "read" }], {});
|
|
167
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
168
|
-
expect(upstream.listToolsFor).not.toHaveBeenCalled();
|
|
169
|
-
expect(catalog).toEqual([{ name: "get_me", description: "(read) proxied to the GitHub MCP", inputSchema: { type: "object" } }]);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("times out a slow upstream and advertises minimal schemas", async () => {
|
|
173
|
-
process.env.GH_BROKER_CATALOG_TIMEOUT_MS = "15";
|
|
174
|
-
upstream.listToolsFor.mockReturnValue(new Promise(() => {})); // never resolves
|
|
175
|
-
const config = makeConfig([{ name: "get_me", mode: "read" }]);
|
|
176
|
-
const catalog = await buildCatalog(config, secrets as any, upstream as any);
|
|
177
|
-
expect(catalog.map((t) => t.name)).toEqual(["get_me"]);
|
|
178
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/timed out after 15ms/));
|
|
179
|
-
});
|
|
180
|
-
});
|