@cldmv/gh-broker 0.8.0 → 0.10.0

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