@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
package/src/verbs/ghcr_push.ts
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/verbs/ghcr_push.ts
|
|
5
|
-
* @Date: 2026-10-31T11:02:57-07:00 (1793469777)
|
|
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-31T13:14:41-07:00 (1793477681)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Custom verbs: ghcr_push, ghcr_push_status, ghcr_tags — push container images to GHCR
|
|
18
|
-
* (ghcr.io/<owner>/<image>) without the agent ever holding a registry credential.
|
|
19
|
-
*
|
|
20
|
-
* The agent writes a `docker save` archive into the drop directory and calls `ghcr_push`.
|
|
21
|
-
* The broker checks the call against `config.ghcr` (owner configured, image on the owner's
|
|
22
|
-
* allowlist, tag well-formed, archive really inside the drop directory, release tag not
|
|
23
|
-
* already taken), exchanges the owner's classic PAT for a registry token, and pushes in a
|
|
24
|
-
* background job. `ghcr_push_status` reports that job; `ghcr_tags` lists an allowlisted
|
|
25
|
-
* image's tags so a push can be verified.
|
|
26
|
-
*
|
|
27
|
-
* What the verbs can't do, by construction: talk to any registry but ghcr.io, push outside
|
|
28
|
-
* `ghcr.io/<owner>/<allowlisted image>`, delete anything, change visibility, or read a
|
|
29
|
-
* file outside the drop directory. No result, error, audit line or log line carries the
|
|
30
|
-
* PAT or the registry token (registry.ts `clean()`).
|
|
31
|
-
*
|
|
32
|
-
* The handlers are the real contract: the MCP server doesn't validate against the
|
|
33
|
-
* advertised schemas, so every key and type is checked here before any network call.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import type { SecretStore } from "../types.ts";
|
|
37
|
-
import { audit } from "../audit.ts";
|
|
38
|
-
import { imageAllowed, type GhcrConfig, type GhcrOwnerConfig } from "./ghcr/config.ts";
|
|
39
|
-
import { openArchive, planImage } from "./ghcr/archive.ts";
|
|
40
|
-
import { Registry, GHCR_BASE } from "./ghcr/registry.ts";
|
|
41
|
-
import { pushImage, RELEASE_TAG } from "./ghcr/push.ts";
|
|
42
|
-
import { JobQueue, type Job } from "./ghcr/jobs.ts";
|
|
43
|
-
|
|
44
|
-
/** OCI repository path component; `/` separates components. */
|
|
45
|
-
const IMAGE = /^[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*)*$/;
|
|
46
|
-
/** OCI tag grammar. */
|
|
47
|
-
const TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
|
|
48
|
-
const JOB_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
49
|
-
|
|
50
|
-
const PUSH_KEYS = new Set(["owner", "image", "tag", "archive"]);
|
|
51
|
-
const STATUS_KEYS = new Set(["owner", "job"]);
|
|
52
|
-
const TAGS_KEYS = new Set(["owner", "image"]);
|
|
53
|
-
|
|
54
|
-
export interface GhcrPushInput {
|
|
55
|
-
owner: string;
|
|
56
|
-
image: string;
|
|
57
|
-
tag: string;
|
|
58
|
-
archive: string;
|
|
59
|
-
}
|
|
60
|
-
export interface GhcrPushStatusInput {
|
|
61
|
-
owner: string;
|
|
62
|
-
job?: string;
|
|
63
|
-
}
|
|
64
|
-
export interface GhcrTagsInput {
|
|
65
|
-
owner: string;
|
|
66
|
-
image: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function strictArgs(verb: string, input: unknown, keys: Set<string>): Record<string, unknown> {
|
|
70
|
-
if (typeof input !== "object" || input === null || Array.isArray(input)) throw new Error(`${verb}: arguments must be an object`);
|
|
71
|
-
const a = input as Record<string, unknown>;
|
|
72
|
-
const unknown = Object.keys(a).filter((k) => !keys.has(k));
|
|
73
|
-
if (unknown.length) throw new Error(`${verb}: unknown argument(s): ${unknown.join(", ")} — expected ${[...keys].join(", ")}`);
|
|
74
|
-
if (typeof a.owner !== "string" || !a.owner.trim()) throw new Error(`${verb}: "owner" must be a non-empty string`);
|
|
75
|
-
return a;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface GhcrServiceOptions {
|
|
79
|
-
/** Registry origin — tests only; production is always https://ghcr.io. */
|
|
80
|
-
base?: string;
|
|
81
|
-
queue?: JobQueue;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export class GhcrService {
|
|
85
|
-
private readonly cfg?: GhcrConfig;
|
|
86
|
-
private readonly secrets: SecretStore;
|
|
87
|
-
private readonly base: string;
|
|
88
|
-
readonly queue: JobQueue;
|
|
89
|
-
|
|
90
|
-
constructor(cfg: GhcrConfig | undefined, secrets: SecretStore, opts: GhcrServiceOptions = {}) {
|
|
91
|
-
this.cfg = cfg;
|
|
92
|
-
this.secrets = secrets;
|
|
93
|
-
this.base = opts.base ?? GHCR_BASE;
|
|
94
|
-
this.queue = opts.queue ?? new JobQueue();
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
private config(verb: string): GhcrConfig {
|
|
98
|
-
if (!this.cfg) throw new Error(`${verb}: GHCR is not configured — add a "ghcr" block to the config (see docs/TOOLS.md#ghcr_push)`);
|
|
99
|
-
return this.cfg;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
private ownerConfig(verb: string, owner: string): GhcrOwnerConfig {
|
|
103
|
-
const o = this.config(verb).owners[owner.toLowerCase()];
|
|
104
|
-
if (!o) throw new Error(`${verb}: owner "${owner}" has no GHCR configuration (config.ghcr.owners)`);
|
|
105
|
-
if (!this.secrets.has(o.credential)) throw new Error(`${verb}: owner "${owner}"'s GHCR credential "${o.credential.replace(/^cred:/, "")}" is not stored (re-run the installer to add it)`);
|
|
106
|
-
return o;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
private image(verb: string, owner: string, oc: GhcrOwnerConfig, raw: unknown): string {
|
|
110
|
-
if (typeof raw !== "string" || !raw) throw new Error(`${verb}: "image" must be a non-empty string`);
|
|
111
|
-
const lower = raw.toLowerCase();
|
|
112
|
-
if (lower.startsWith("ghcr.io/") || lower.startsWith(`${owner}/`)) {
|
|
113
|
-
throw new Error(`${verb}: "image" is the name under ghcr.io/${owner}/ — pass e.g. "hoot", not "${raw}"`);
|
|
114
|
-
}
|
|
115
|
-
if (!IMAGE.test(raw) || raw.length + owner.length + 1 > 255) throw new Error(`${verb}: "image" "${raw}" is not a valid image name (lowercase [a-z0-9], separators . _ __ -, "/" between segments)`);
|
|
116
|
-
if (!imageAllowed(oc, raw)) throw new Error(`${verb}: ghcr.io/${owner}/${raw} is not on the allowlist (config.ghcr.owners.${owner}.images: ${oc.images.join(", ")})`);
|
|
117
|
-
return raw;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
private registry(owner: string, oc: GhcrOwnerConfig, image: string): Registry {
|
|
121
|
-
return new Registry({ base: this.base, username: oc.username, password: this.secrets.resolve(oc.credential), repository: `${owner}/${image}` });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async push(input: unknown): Promise<Omit<Job, "progress" | "result">> {
|
|
125
|
-
const verb = "ghcr_push";
|
|
126
|
-
const a = strictArgs(verb, input, PUSH_KEYS);
|
|
127
|
-
const owner = (a.owner as string).toLowerCase();
|
|
128
|
-
const cfg = this.config(verb);
|
|
129
|
-
const oc = this.ownerConfig(verb, owner);
|
|
130
|
-
const image = this.image(verb, owner, oc, a.image);
|
|
131
|
-
if (typeof a.tag !== "string" || !TAG.test(a.tag)) throw new Error(`${verb}: "tag" must be a valid tag ([A-Za-z0-9_][A-Za-z0-9._-]{0,127})`);
|
|
132
|
-
const tag = a.tag;
|
|
133
|
-
|
|
134
|
-
const archive = openArchive(a.archive, cfg.dropDir, cfg.maxArchiveBytes);
|
|
135
|
-
let handedOff = false;
|
|
136
|
-
let registry: Registry | undefined;
|
|
137
|
-
try {
|
|
138
|
-
const plan = planImage(archive.fd, archive.size);
|
|
139
|
-
registry = this.registry(owner, oc, image);
|
|
140
|
-
const ref = `${registry.host}/${owner}/${image}`;
|
|
141
|
-
// First network calls: proves the credential can push here, and refuses a taken release tag
|
|
142
|
-
// before the job is queued (push.ts re-checks right before the manifest PUT).
|
|
143
|
-
await registry.login("push,pull");
|
|
144
|
-
if (cfg.protectReleaseTags && RELEASE_TAG.test(tag) && (await registry.manifestExists(tag))) {
|
|
145
|
-
throw new Error(`${verb}: tag ${tag} already exists on ${ref} and release tags are protected — push a new version instead`);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const reg = registry;
|
|
149
|
-
const job = this.queue.submit(
|
|
150
|
-
{ owner, image: ref, tag, archive: archive.path, archiveBytes: archive.size, format: plan.format },
|
|
151
|
-
{
|
|
152
|
-
run: (progress) => pushImage(reg, archive.fd, plan, tag, { protectReleaseTags: cfg.protectReleaseTags, onProgress: progress }),
|
|
153
|
-
describeError: (err) => reg.clean((err as Error)?.message ?? String(err)),
|
|
154
|
-
finally: (done) => {
|
|
155
|
-
archive.close();
|
|
156
|
-
audit({
|
|
157
|
-
tool: verb,
|
|
158
|
-
owner,
|
|
159
|
-
mode: "write",
|
|
160
|
-
repo: ref,
|
|
161
|
-
decision: "custom",
|
|
162
|
-
ok: done.state === "succeeded",
|
|
163
|
-
detail:
|
|
164
|
-
done.state === "succeeded"
|
|
165
|
-
? `job=${done.job} tag=${tag} digest=${done.result?.digest} size=${done.result?.size} uploaded=${done.result?.blobsUploaded} skipped=${done.result?.blobsSkipped} archive=${archive.path}`
|
|
166
|
-
: `job=${done.job} tag=${tag} archive=${archive.path} error=${done.error}`
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
);
|
|
171
|
-
handedOff = true;
|
|
172
|
-
audit({ tool: verb, owner, mode: "write", repo: ref, decision: "custom", ok: true, detail: `job=${job.job} queued tag=${tag} archive=${archive.path} bytes=${archive.size} format=${plan.format} layers=${plan.layers.length}` });
|
|
173
|
-
return { ...job };
|
|
174
|
-
} catch (err) {
|
|
175
|
-
// Anything thrown before hand-off may come from the registry: scrub it like a job error.
|
|
176
|
-
if (!registry) throw err; // archive/plan errors carry no secret
|
|
177
|
-
throw new Error(registry.clean((err as Error)?.message ?? String(err)));
|
|
178
|
-
} finally {
|
|
179
|
-
if (!handedOff) archive.close();
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
status(input: unknown): Job | { jobs: Job[] } {
|
|
184
|
-
const verb = "ghcr_push_status";
|
|
185
|
-
const a = strictArgs(verb, input, STATUS_KEYS);
|
|
186
|
-
const owner = (a.owner as string).toLowerCase();
|
|
187
|
-
this.ownerConfig(verb, owner);
|
|
188
|
-
if (a.job === undefined) return { jobs: this.queue.list(owner) };
|
|
189
|
-
if (typeof a.job !== "string" || !JOB_ID.test(a.job)) throw new Error(`${verb}: "job" must be a job id returned by ghcr_push`);
|
|
190
|
-
const job = this.queue.get(a.job);
|
|
191
|
-
// A job of another owner reads as unknown: status never crosses owners.
|
|
192
|
-
if (!job || job.owner !== owner) throw new Error(`${verb}: no job ${a.job} for owner ${owner} (jobs are kept in memory; a broker restart forgets them)`);
|
|
193
|
-
return job;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async tags(input: unknown): Promise<{ image: string; exists: boolean; tags: string[] }> {
|
|
197
|
-
const verb = "ghcr_tags";
|
|
198
|
-
const a = strictArgs(verb, input, TAGS_KEYS);
|
|
199
|
-
const owner = (a.owner as string).toLowerCase();
|
|
200
|
-
const oc = this.ownerConfig(verb, owner);
|
|
201
|
-
const image = this.image(verb, owner, oc, a.image);
|
|
202
|
-
const registry = this.registry(owner, oc, image);
|
|
203
|
-
try {
|
|
204
|
-
await registry.login("pull");
|
|
205
|
-
const tags = await registry.listTags();
|
|
206
|
-
return { image: `${registry.host}/${owner}/${image}`, exists: tags !== undefined, tags: tags ?? [] };
|
|
207
|
-
} catch (err) {
|
|
208
|
-
throw new Error(registry.clean((err as Error)?.message ?? String(err)));
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const owner = { type: "string", description: "registry owner (org/user login) — must be configured in config.ghcr.owners" };
|
|
214
|
-
const image = { type: "string", description: 'image name under ghcr.io/<owner>/, e.g. "hoot" — must match config.ghcr.owners.<owner>.images' };
|
|
215
|
-
|
|
216
|
-
export const GHCR_PUSH_SCHEMA = {
|
|
217
|
-
type: "object" as const,
|
|
218
|
-
required: ["owner", "image", "tag", "archive"],
|
|
219
|
-
additionalProperties: false,
|
|
220
|
-
properties: {
|
|
221
|
-
owner,
|
|
222
|
-
image,
|
|
223
|
-
tag: { type: "string", pattern: "^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$", description: "tag to push, e.g. \"1.4.0\" or \"latest\"" },
|
|
224
|
-
archive: {
|
|
225
|
-
type: "string",
|
|
226
|
-
description: "a `docker save` archive (docker-archive or OCI layout) inside the configured drop directory — absolute, or relative to it. Must be a regular, group-readable file; symlinks and hard links are refused."
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
export const GHCR_PUSH_STATUS_SCHEMA = {
|
|
232
|
-
type: "object" as const,
|
|
233
|
-
required: ["owner"],
|
|
234
|
-
additionalProperties: false,
|
|
235
|
-
properties: {
|
|
236
|
-
owner,
|
|
237
|
-
job: { type: "string", description: "job id returned by ghcr_push; omit to list this owner's recent jobs" }
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
export const GHCR_TAGS_SCHEMA = {
|
|
242
|
-
type: "object" as const,
|
|
243
|
-
required: ["owner", "image"],
|
|
244
|
-
additionalProperties: false,
|
|
245
|
-
properties: { owner, image }
|
|
246
|
-
};
|
|
247
|
-
|
|
248
|
-
/** Advertised description + schema per GHCR verb, keyed by tool name (catalog.ts). */
|
|
249
|
-
export const GHCR_CATALOG: Record<string, { description: string; inputSchema: { type: "object"; [k: string]: unknown } }> = {
|
|
250
|
-
ghcr_push: {
|
|
251
|
-
description:
|
|
252
|
-
"Push a container image to ghcr.io/<owner>/<image>:<tag> from a `docker save` archive in the broker's drop directory, without holding a registry token. The image must be on config.ghcr's allowlist; an existing release tag (1.2.3 / v1.2.3) is never overwritten while protectReleaseTags is on. Starts a background job and returns its id at once — poll ghcr_push_status. Runs on the owner's GHCR credential (classic PAT, write:packages).",
|
|
253
|
-
inputSchema: GHCR_PUSH_SCHEMA
|
|
254
|
-
},
|
|
255
|
-
ghcr_push_status: {
|
|
256
|
-
description:
|
|
257
|
-
"Progress of a ghcr_push job (phase, blobs uploaded/skipped, bytes sent), then its final manifest digest or error. Omit job to list this owner's recent jobs. Jobs are kept in memory; a broker restart forgets them.",
|
|
258
|
-
inputSchema: GHCR_PUSH_STATUS_SCHEMA
|
|
259
|
-
},
|
|
260
|
-
ghcr_tags: {
|
|
261
|
-
description: "List the tags of an allowlisted image on ghcr.io/<owner>/<image>, e.g. to verify a push landed. Runs on the owner's GHCR credential (pull scope).",
|
|
262
|
-
inputSchema: GHCR_TAGS_SCHEMA
|
|
263
|
-
}
|
|
264
|
-
};
|
|
@@ -1,329 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/verbs/issue_labels.ts
|
|
5
|
-
* @Date: 2026-10-16T17:14:34-07:00 (1792196074)
|
|
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-16T17:25:33-07:00 (1792196733)
|
|
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 verb: issue_labels — edit the labels on one issue or PR without replacing the
|
|
20
|
-
* whole set.
|
|
21
|
-
*
|
|
22
|
-
* Why a custom verb: the hosted MCP's only label write path is `issue_write`, whose
|
|
23
|
-
* `labels` argument REPLACES every label on the issue. Changing one status label that way
|
|
24
|
-
* silently drops the rest (CLDMV/gh-broker#117), and reading the set then writing the
|
|
25
|
-
* merged set back loses any label someone else adds in between. GitHub's REST API has
|
|
26
|
-
* per-label endpoints that touch only the labels named, so this verb drives those:
|
|
27
|
-
*
|
|
28
|
-
* - `add` → POST /repos/{o}/{r}/issues/{n}/labels (server-side append)
|
|
29
|
-
* - `remove` → DELETE /repos/{o}/{r}/issues/{n}/labels/{name} (one call per label)
|
|
30
|
-
* - `swap` → a POST of `to`, then a DELETE for every current label starting with `prefix`
|
|
31
|
-
* - `set` → PUT /repos/{o}/{r}/issues/{n}/labels (explicit full replace, never empty)
|
|
32
|
-
*
|
|
33
|
-
* Writes run create → add → remove, so a call that fails part-way never leaves the issue
|
|
34
|
-
* with fewer labels than it should have (a swap whose add is rate-limited keeps its old
|
|
35
|
-
* status label). The error then names exactly what was applied and what was not.
|
|
36
|
-
*
|
|
37
|
-
* Issues and PRs share these endpoints, so a PR number works the same way.
|
|
38
|
-
*
|
|
39
|
-
* Label names to ADD (via `add`, `swap.to` or `set`) must already exist in the repo —
|
|
40
|
-
* a typo would otherwise create a stray label silently. `create_missing: true` creates
|
|
41
|
-
* them instead. Every add-side name is checked before any write, so a refused call
|
|
42
|
-
* changes nothing.
|
|
43
|
-
*
|
|
44
|
-
* GitHub label names are case-insensitive; all matching here (existence, swap prefix,
|
|
45
|
-
* add/remove overlap) is too, and the repo's own spelling is what gets written.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
export interface IssueLabelsSwap {
|
|
49
|
-
/** Every current label starting with this (e.g. "status: ") is removed. */
|
|
50
|
-
prefix: string;
|
|
51
|
-
/** Then these are added. One label or several. */
|
|
52
|
-
to: string | string[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface IssueLabelsInput {
|
|
56
|
-
owner: string;
|
|
57
|
-
repo: string;
|
|
58
|
-
/** Issue or pull-request number. */
|
|
59
|
-
issue_number: number;
|
|
60
|
-
add?: string | string[];
|
|
61
|
-
remove?: string | string[];
|
|
62
|
-
swap?: IssueLabelsSwap;
|
|
63
|
-
/** Replace the whole set. Cannot be combined with add/remove/swap. */
|
|
64
|
-
set?: string | string[];
|
|
65
|
-
/** Create add-side labels that don't exist in the repo instead of refusing. */
|
|
66
|
-
create_missing?: boolean;
|
|
67
|
-
/** Hex color (no #) for labels created via create_missing. */
|
|
68
|
-
color?: string;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface IssueLabelsResult {
|
|
72
|
-
issue: string;
|
|
73
|
-
/** The issue's label set after the call. */
|
|
74
|
-
labels: string[];
|
|
75
|
-
added: string[];
|
|
76
|
-
removed: string[];
|
|
77
|
-
created: string[];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const DEFAULT_COLOR = "ededed";
|
|
81
|
-
/** GitHub's own per-issue label limit; also bounds the requests one call can make. */
|
|
82
|
-
export const MAX_LABELS = 100;
|
|
83
|
-
const INPUT_KEYS = new Set(["owner", "repo", "issue_number", "add", "remove", "swap", "set", "create_missing", "color"]);
|
|
84
|
-
const SWAP_KEYS = new Set(["prefix", "to"]);
|
|
85
|
-
|
|
86
|
-
const fail = (msg: string): never => {
|
|
87
|
-
throw new Error(`issue_labels: ${msg}`);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
function toList(v: unknown, field: string): string[] {
|
|
91
|
-
if (v === undefined) return [];
|
|
92
|
-
const arr = Array.isArray(v) ? v : [v];
|
|
93
|
-
if (arr.length === 0) fail(`"${field}" is empty — pass at least one label name`);
|
|
94
|
-
for (const x of arr) {
|
|
95
|
-
if (typeof x !== "string" || !x.trim()) fail(`"${field}" must be a label name or a list of label names`);
|
|
96
|
-
}
|
|
97
|
-
const list = dedupe((arr as string[]).map((x) => x.trim()));
|
|
98
|
-
if (list.length > MAX_LABELS) fail(`"${field}" has ${list.length} labels — at most ${MAX_LABELS} per call`);
|
|
99
|
-
return list;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const key = (s: string): string => s.toLowerCase();
|
|
103
|
-
|
|
104
|
-
function dedupe(list: string[]): string[] {
|
|
105
|
-
const seen = new Set<string>();
|
|
106
|
-
return list.filter((x) => (seen.has(key(x)) ? false : (seen.add(key(x)), true)));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function statusOf(err: unknown): number | undefined {
|
|
110
|
-
return (err as { status?: number })?.status;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const fmt = (list: string[]): string => `[${list.join(", ")}]`;
|
|
114
|
-
|
|
115
|
-
type Label = { name: string };
|
|
116
|
-
const names = (data: unknown): string[] => (Array.isArray(data) ? (data as Label[]).map((l) => l.name) : []);
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* The handler is the real contract: the MCP server does not validate against the
|
|
120
|
-
* advertised inputSchema, so every key and type is checked here, before any request.
|
|
121
|
-
*/
|
|
122
|
-
function validate(input: unknown) {
|
|
123
|
-
if (typeof input !== "object" || input === null || Array.isArray(input)) fail("arguments must be an object");
|
|
124
|
-
const a = input as Record<string, unknown>;
|
|
125
|
-
const unknown = Object.keys(a).filter((k) => !INPUT_KEYS.has(k));
|
|
126
|
-
if (unknown.length) fail(`unknown argument(s): ${unknown.join(", ")} — expected ${[...INPUT_KEYS].join(", ")}`);
|
|
127
|
-
if (typeof a.owner !== "string" || !a.owner.trim()) fail('"owner" must be a non-empty string');
|
|
128
|
-
if (typeof a.repo !== "string" || !a.repo.trim()) fail('"repo" must be a non-empty string');
|
|
129
|
-
if (typeof a.issue_number !== "number" || !Number.isInteger(a.issue_number) || a.issue_number <= 0) fail('"issue_number" must be a positive integer');
|
|
130
|
-
if (a.create_missing !== undefined && typeof a.create_missing !== "boolean") fail('"create_missing" must be a boolean');
|
|
131
|
-
if (a.color !== undefined && (typeof a.color !== "string" || !/^#?[0-9a-f]{6}$/i.test(a.color))) fail('"color" must be a 6-digit hex color, e.g. "ededed"');
|
|
132
|
-
|
|
133
|
-
const add = toList(a.add, "add");
|
|
134
|
-
const remove = toList(a.remove, "remove");
|
|
135
|
-
const set = a.set === undefined ? undefined : toList(a.set, "set");
|
|
136
|
-
let swapTo: string[] = [];
|
|
137
|
-
let swapPrefix: string | undefined;
|
|
138
|
-
if (a.swap !== undefined) {
|
|
139
|
-
const sw = a.swap as Record<string, unknown>;
|
|
140
|
-
if (typeof sw !== "object" || sw === null || Array.isArray(sw)) fail('"swap" must be an object { prefix, to }');
|
|
141
|
-
const extra = Object.keys(sw).filter((k) => !SWAP_KEYS.has(k));
|
|
142
|
-
if (extra.length) fail(`unknown "swap" key(s): ${extra.join(", ")} — expected prefix, to`);
|
|
143
|
-
if (typeof sw.prefix !== "string" || !sw.prefix) fail('"swap.prefix" is required');
|
|
144
|
-
if (sw.to === undefined) fail('"swap.to" needs at least one label');
|
|
145
|
-
swapPrefix = sw.prefix as string;
|
|
146
|
-
swapTo = toList(sw.to, "swap.to");
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (set !== undefined && (add.length || remove.length || swapPrefix !== undefined)) {
|
|
150
|
-
fail('"set" replaces the whole label set and cannot be combined with add/remove/swap');
|
|
151
|
-
}
|
|
152
|
-
if (set === undefined && !add.length && !remove.length && swapPrefix === undefined) fail("nothing to do — pass add, remove, swap or set");
|
|
153
|
-
const addSide = dedupe([...add, ...swapTo]);
|
|
154
|
-
if (addSide.length > MAX_LABELS) fail(`${addSide.length} labels to add — at most ${MAX_LABELS} per call`);
|
|
155
|
-
const removeKeys = new Set(remove.map(key));
|
|
156
|
-
const clash = addSide.filter((l) => removeKeys.has(key(l)));
|
|
157
|
-
if (clash.length) fail(`label(s) both added and removed: ${clash.join(", ")}`);
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
owner: a.owner as string,
|
|
161
|
-
repo: a.repo as string,
|
|
162
|
-
issue_number: a.issue_number as number,
|
|
163
|
-
remove,
|
|
164
|
-
set,
|
|
165
|
-
swapPrefix,
|
|
166
|
-
addSide,
|
|
167
|
-
createMissing: a.create_missing === true,
|
|
168
|
-
color: ((a.color as string | undefined) ?? DEFAULT_COLOR).replace(/^#/, "")
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export async function issueLabels(octokit: Octokit, input: IssueLabelsInput): Promise<IssueLabelsResult> {
|
|
173
|
-
const { owner, repo, issue_number, remove, set, swapPrefix, addSide, createMissing, color } = validate(input);
|
|
174
|
-
const issue = `${owner}/${repo}#${issue_number}`;
|
|
175
|
-
const base = { owner, repo, issue_number };
|
|
176
|
-
const readLabels = async (): Promise<string[]> =>
|
|
177
|
-
names((await octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}/labels", { ...base, per_page: MAX_LABELS })).data);
|
|
178
|
-
|
|
179
|
-
// 0. The current set: needed for swap's prefix matches, for the spelling of removals and
|
|
180
|
-
// for reporting what changed. It also fails a wrong issue number (404) before anything
|
|
181
|
-
// is written or created.
|
|
182
|
-
const before = await readLabels();
|
|
183
|
-
const beforeKeys = new Set(before.map(key));
|
|
184
|
-
const onIssue = (n: string): string | undefined => before.find((l) => key(l) === key(n));
|
|
185
|
-
|
|
186
|
-
// 1. Resolve every add-side label against the repo before writing anything.
|
|
187
|
-
const toResolve = set ?? addSide;
|
|
188
|
-
const resolved: string[] = [];
|
|
189
|
-
const missing: string[] = [];
|
|
190
|
-
for (const name of toResolve) {
|
|
191
|
-
try {
|
|
192
|
-
const res = await octokit.request("GET /repos/{owner}/{repo}/labels/{name}", { owner, repo, name });
|
|
193
|
-
resolved.push((res.data as Label).name);
|
|
194
|
-
} catch (err) {
|
|
195
|
-
if (statusOf(err) !== 404) throw err;
|
|
196
|
-
missing.push(name);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
if (missing.length && !createMissing) {
|
|
200
|
-
fail(`label(s) not found in ${owner}/${repo}: ${missing.join(", ")} — nothing was changed. Check the spelling (list_label), or pass create_missing: true to create them.`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// 2. Plan the removals: only labels actually on the issue, in the issue's own spelling.
|
|
204
|
-
// swap removes every prefix match except a label this call also adds — that one stays
|
|
205
|
-
// put instead of being deleted and re-added.
|
|
206
|
-
const removals: string[] = [];
|
|
207
|
-
if (set === undefined) {
|
|
208
|
-
for (const n of remove) {
|
|
209
|
-
const hit = onIssue(n);
|
|
210
|
-
if (hit) removals.push(hit);
|
|
211
|
-
}
|
|
212
|
-
if (swapPrefix !== undefined) {
|
|
213
|
-
const keep = new Set(addSide.map(key));
|
|
214
|
-
const p = key(swapPrefix);
|
|
215
|
-
for (const l of before) if (key(l).startsWith(p) && !keep.has(key(l))) removals.push(l);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
const plannedRemovals = dedupe(removals);
|
|
219
|
-
|
|
220
|
-
// 3. Writes. Order is chosen so a failure part-way never leaves the issue with LESS than
|
|
221
|
-
// it started with plus what was asked: create, then add (POST), and removals last.
|
|
222
|
-
// Any failure is rethrown with exactly what was applied and what was not.
|
|
223
|
-
const created: string[] = [];
|
|
224
|
-
let addedDone = false;
|
|
225
|
-
const removed: string[] = [];
|
|
226
|
-
let latest: string[] | undefined;
|
|
227
|
-
const canonical = (): string[] => [...resolved, ...created];
|
|
228
|
-
const canon = (n: string): string => canonical().find((c) => key(c) === key(n)) ?? n;
|
|
229
|
-
try {
|
|
230
|
-
for (const name of missing) {
|
|
231
|
-
const res = await octokit.request("POST /repos/{owner}/{repo}/labels", { owner, repo, name, color });
|
|
232
|
-
created.push((res.data as Label).name);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (set !== undefined) {
|
|
236
|
-
const res = await octokit.request("PUT /repos/{owner}/{repo}/issues/{issue_number}/labels", { ...base, labels: set.map(canon) });
|
|
237
|
-
latest = names(res.data);
|
|
238
|
-
addedDone = true;
|
|
239
|
-
return result(latest, before);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (addSide.length) {
|
|
243
|
-
const res = await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/labels", { ...base, labels: addSide.map(canon) });
|
|
244
|
-
latest = names(res.data);
|
|
245
|
-
addedDone = true;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
for (const name of plannedRemovals) {
|
|
249
|
-
try {
|
|
250
|
-
const res = await octokit.request("DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}", { ...base, name });
|
|
251
|
-
latest = names(res.data);
|
|
252
|
-
removed.push(name);
|
|
253
|
-
} catch (err) {
|
|
254
|
-
// Someone removed it since the read above: already the state asked for.
|
|
255
|
-
if (statusOf(err) !== 404) throw err;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
} catch (err) {
|
|
259
|
-
const applied: string[] = [];
|
|
260
|
-
const notApplied: string[] = [];
|
|
261
|
-
const part = (label: string, done: string[], all: string[]) => {
|
|
262
|
-
const doneKeys = new Set(done.map(key));
|
|
263
|
-
if (done.length) applied.push(`${label} ${fmt(done)}`);
|
|
264
|
-
const rest = all.filter((l) => !doneKeys.has(key(l)));
|
|
265
|
-
if (rest.length) notApplied.push(`${label} ${fmt(rest)}`);
|
|
266
|
-
};
|
|
267
|
-
part("created", created, missing);
|
|
268
|
-
if (set !== undefined) part("set", addedDone ? set : [], set);
|
|
269
|
-
else part("added", addedDone ? addSide.map(canon) : [], addSide.map(canon));
|
|
270
|
-
part("removed", removed, plannedRemovals);
|
|
271
|
-
const e = new Error(
|
|
272
|
-
`issue_labels: failed part-way on ${issue}: ${(err as Error).message}. Applied: ${applied.join("; ") || "nothing"}. Not applied: ${notApplied.join("; ") || "nothing"}.`,
|
|
273
|
-
{ cause: err }
|
|
274
|
-
);
|
|
275
|
-
(e as Error & { status?: number }).status = statusOf(err);
|
|
276
|
-
throw e;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return result(latest ?? (await readLabels()), removed);
|
|
280
|
-
|
|
281
|
-
function result(labels: string[], removedFrom: string[]): IssueLabelsResult {
|
|
282
|
-
const afterKeys = new Set(labels.map(key));
|
|
283
|
-
// Only what this call asked for — a label someone else added meanwhile isn't ours to report.
|
|
284
|
-
const requested = new Set(toResolve.map(key));
|
|
285
|
-
return {
|
|
286
|
-
issue,
|
|
287
|
-
labels,
|
|
288
|
-
added: labels.filter((l) => requested.has(key(l)) && !beforeKeys.has(key(l))),
|
|
289
|
-
removed: removedFrom.filter((l) => !afterKeys.has(key(l))),
|
|
290
|
-
created
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const labelList = (description: string) => ({
|
|
296
|
-
description,
|
|
297
|
-
oneOf: [{ type: "string" }, { type: "array", items: { type: "string" }, minItems: 1, maxItems: MAX_LABELS }]
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
/** JSON Schema advertised to the agent for issue_labels. */
|
|
301
|
-
export const ISSUE_LABELS_SCHEMA = {
|
|
302
|
-
type: "object" as const,
|
|
303
|
-
required: ["owner", "repo", "issue_number"],
|
|
304
|
-
additionalProperties: false,
|
|
305
|
-
properties: {
|
|
306
|
-
owner: { type: "string", description: "repository owner (org/user login) — selects the write credential" },
|
|
307
|
-
repo: { type: "string", description: "repository name" },
|
|
308
|
-
issue_number: { type: "number", description: "issue or pull-request number" },
|
|
309
|
-
add: labelList("label(s) to add; ones already on the issue are a no-op"),
|
|
310
|
-
remove: labelList("label(s) to remove; ones the issue doesn't have are a no-op"),
|
|
311
|
-
swap: {
|
|
312
|
-
type: "object",
|
|
313
|
-
additionalProperties: false,
|
|
314
|
-
required: ["prefix", "to"],
|
|
315
|
-
description: 'remove every current label starting with prefix, then add to — e.g. { "prefix": "status: ", "to": "status: verified" }',
|
|
316
|
-
properties: {
|
|
317
|
-
prefix: { type: "string", description: 'label-name prefix, e.g. "status: "' },
|
|
318
|
-
to: labelList("label(s) to add after the prefix matches are removed")
|
|
319
|
-
}
|
|
320
|
-
},
|
|
321
|
-
set: labelList("replace the WHOLE label set with these (at least one); cannot be combined with add/remove/swap"),
|
|
322
|
-
create_missing: {
|
|
323
|
-
type: "boolean",
|
|
324
|
-
default: false,
|
|
325
|
-
description: "create add-side labels that don't exist in the repo; without it an unknown name refuses the call and nothing changes"
|
|
326
|
-
},
|
|
327
|
-
color: { type: "string", pattern: "^#?[0-9a-fA-F]{6}$", description: `6-digit hex color (with or without #) for labels created via create_missing; default ${DEFAULT_COLOR}` }
|
|
328
|
-
}
|
|
329
|
-
};
|
package/src/verbs/link_issue.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/verbs/link_issue.ts
|
|
5
|
-
* @Date: 2026-07-07T00:24:51-07:00 (1783409091)
|
|
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 verb: link_issue — create an EXPLICIT PR→issue closing reference via the
|
|
20
|
-
* `addClosingIssueReference` GraphQL mutation.
|
|
21
|
-
*
|
|
22
|
-
* Why a custom verb and why an App token:
|
|
23
|
-
* - The hosted GitHub MCP has no tool for PR→issue closing links (verified), so
|
|
24
|
-
* it can't be proxied — it's a direct GitHub API call.
|
|
25
|
-
* - `addClosingIssueReference` is NOT in the fine-grained-PAT GraphQL schema
|
|
26
|
-
* (GitHub returns "Field doesn't exist", distinct from a permission error). It
|
|
27
|
-
* is available to GitHub **App** tokens — exactly what CLDMV's own
|
|
28
|
-
* `sync-closing-issues` action uses. So this runs on an App-authed Octokit.
|
|
29
|
-
* - We deliberately do NOT depend on commit-message keywords for linkage.
|
|
30
|
-
*
|
|
31
|
-
* Uses Octokit (`octokit.graphql`) — GitHub's official SDK — not a hand-rolled fetch.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
export interface LinkIssueInput {
|
|
35
|
-
owner: string;
|
|
36
|
-
repo: string;
|
|
37
|
-
/** pull request number */
|
|
38
|
-
pull: number;
|
|
39
|
-
/** issue number to link as a closing reference */
|
|
40
|
-
issue: number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const RESOLVE_IDS = `
|
|
44
|
-
query($owner: String!, $repo: String!, $pr: Int!, $issue: Int!) {
|
|
45
|
-
repository(owner: $owner, name: $repo) {
|
|
46
|
-
pullRequest(number: $pr) { id }
|
|
47
|
-
issue(number: $issue) { id }
|
|
48
|
-
}
|
|
49
|
-
}`;
|
|
50
|
-
|
|
51
|
-
const ADD_CLOSING_REF = `
|
|
52
|
-
mutation($prId: ID!, $issueId: ID!) {
|
|
53
|
-
addClosingIssueReference(input: { pullRequestId: $prId, issueId: $issueId }) {
|
|
54
|
-
pullRequest { id }
|
|
55
|
-
}
|
|
56
|
-
}`;
|
|
57
|
-
|
|
58
|
-
/** Resolve node IDs, then create the closing reference. Returns the linked node IDs. */
|
|
59
|
-
export async function linkIssue(octokit: Octokit, input: LinkIssueInput): Promise<{ pullRequestId: string; issueId: string }> {
|
|
60
|
-
const ids = await octokit.graphql<{
|
|
61
|
-
repository: { pullRequest: { id: string } | null; issue: { id: string } | null };
|
|
62
|
-
}>(RESOLVE_IDS, { owner: input.owner, repo: input.repo, pr: input.pull, issue: input.issue });
|
|
63
|
-
|
|
64
|
-
const pullRequestId = ids.repository.pullRequest?.id;
|
|
65
|
-
const issueId = ids.repository.issue?.id;
|
|
66
|
-
if (!pullRequestId) throw new Error(`link_issue: PR #${input.pull} not found in ${input.owner}/${input.repo}`);
|
|
67
|
-
if (!issueId) throw new Error(`link_issue: issue #${input.issue} not found in ${input.owner}/${input.repo}`);
|
|
68
|
-
|
|
69
|
-
await octokit.graphql(ADD_CLOSING_REF, { prId: pullRequestId, issueId });
|
|
70
|
-
return { pullRequestId, issueId };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** JSON Schema advertised to the agent for this custom verb. */
|
|
74
|
-
export const LINK_ISSUE_SCHEMA = {
|
|
75
|
-
type: "object" as const,
|
|
76
|
-
required: ["owner", "repo", "pull", "issue"],
|
|
77
|
-
additionalProperties: false,
|
|
78
|
-
properties: {
|
|
79
|
-
owner: { type: "string", description: "repository owner (org/user login)" },
|
|
80
|
-
repo: { type: "string", description: "repository name" },
|
|
81
|
-
pull: { type: "integer", description: "pull request number" },
|
|
82
|
-
issue: { type: "integer", description: "issue number to close when the PR merges to the default branch" }
|
|
83
|
-
}
|
|
84
|
-
};
|