@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
|
@@ -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
|
-
};
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/verbs/release-analysis.ts
|
|
5
|
-
* @Date: 2026-10-04T15:40:09-07:00 (1791153609)
|
|
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-04T16:41:24-07:00 (1791157284)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Release-readiness analysis for report_release_prs `analyze: true`.
|
|
18
|
-
*
|
|
19
|
-
* This is the decision logic the release-docs skill used to walk by hand: what is still open against
|
|
20
|
-
* the release's integration branch, whether an existing changelog actually covers what merged, and
|
|
21
|
-
* the resulting verdict. The pure helpers (parsing, classification, coverage, verdict) are exported
|
|
22
|
-
* for tests; `analyzeRelease` does the reads.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import type { Octokit } from "@octokit/core";
|
|
26
|
-
import { CallBudget, CallBudgetExhausted, RateLimited, paginate, reqOne } from "./rest-client.ts";
|
|
27
|
-
|
|
28
|
-
export type OpenPrKind = "doc" | "dependency" | "feature" | "draft";
|
|
29
|
-
|
|
30
|
-
export interface OpenPr {
|
|
31
|
-
number: number;
|
|
32
|
-
title: string;
|
|
33
|
-
url: string;
|
|
34
|
-
kind: OpenPrKind;
|
|
35
|
-
/** Version named in a doc PR's title (`v1.2.3`), when it has one. */
|
|
36
|
-
version?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type FreshnessStatus = "current" | "minor-gap" | "stale" | "version-drift" | "thin";
|
|
40
|
-
|
|
41
|
-
export interface CoverageGap {
|
|
42
|
-
number: number;
|
|
43
|
-
title: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface Freshness {
|
|
47
|
-
status: FreshnessStatus;
|
|
48
|
-
/** Non-doc PRs that shipped in this release (the expected coverage). */
|
|
49
|
-
expectedPrs: number;
|
|
50
|
-
/** User-facing PRs the changelog never mentions — these make it stale. */
|
|
51
|
-
missingUserFacing: CoverageGap[];
|
|
52
|
-
/** CI / chore / style / test PRs it never mentions — reported, but not stale on their own. */
|
|
53
|
-
missingInternal: CoverageGap[];
|
|
54
|
-
/** The changelog's own heading carries the release PR's current version. */
|
|
55
|
-
headingMatchesVersion: boolean;
|
|
56
|
-
/** The README What's New block names this version. */
|
|
57
|
-
readmeMentionsVersion: boolean;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export type ReleaseVerdict = "done" | "in-flight" | "wait" | "gray" | "write" | "update" | "inspect";
|
|
61
|
-
|
|
62
|
-
export interface ReleaseAnalysis {
|
|
63
|
-
openPrs: OpenPr[];
|
|
64
|
-
/** Set only when a changelog file exists. */
|
|
65
|
-
freshness?: Freshness;
|
|
66
|
-
/** A doc PR's version differs from the release PR's current version. */
|
|
67
|
-
docVersionMismatch?: boolean;
|
|
68
|
-
verdict: ReleaseVerdict;
|
|
69
|
-
/** One line explaining the verdict. */
|
|
70
|
-
reason: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const INTERNAL_TYPES = new Set(["ci", "chore", "style", "test"]);
|
|
74
|
-
|
|
75
|
-
/** `feat(scope)!: x` -> `feat`. Returns "" when the subject is not conventional. */
|
|
76
|
-
export function commitType(subject: string): string {
|
|
77
|
-
const m = /^([a-z]+)(?:\([^)]*\))?!?:/i.exec(subject.trim());
|
|
78
|
-
return m ? m[1]!.toLowerCase() : "";
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface ReleaseCommitPr {
|
|
82
|
-
number: number;
|
|
83
|
-
title: string;
|
|
84
|
-
type: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* PR numbers shipped by a release, from the commits of its release PR. Only the first line of each
|
|
89
|
-
* commit is read: squash bodies list other PR numbers and would fake coverage. Docs PRs (they describe
|
|
90
|
-
* the release rather than being part of it) and the version bump are dropped.
|
|
91
|
-
*/
|
|
92
|
-
export function releaseCommitPrs(messages: string[]): ReleaseCommitPr[] {
|
|
93
|
-
const seen = new Map<number, ReleaseCommitPr>();
|
|
94
|
-
for (const message of messages) {
|
|
95
|
-
const subject = message.split("\n", 1)[0]!.trim();
|
|
96
|
-
let number: number | undefined;
|
|
97
|
-
let title = subject;
|
|
98
|
-
const squash = /\(#(\d+)\)\s*$/.exec(subject);
|
|
99
|
-
const merge = /^Merge pull request #(\d+)\b/.exec(subject);
|
|
100
|
-
if (squash) {
|
|
101
|
-
number = Number(squash[1]);
|
|
102
|
-
title = subject.slice(0, squash.index).trim();
|
|
103
|
-
} else if (merge) {
|
|
104
|
-
number = Number(merge[1]);
|
|
105
|
-
}
|
|
106
|
-
if (number === undefined) continue;
|
|
107
|
-
const type = commitType(title);
|
|
108
|
-
if (type === "docs" || /^chore: bump version/i.test(title)) continue;
|
|
109
|
-
if (!seen.has(number)) seen.set(number, { number, title, type });
|
|
110
|
-
}
|
|
111
|
-
return [...seen.values()];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Every PR number a changelog mentions, by `#N` or `/pull/N`. */
|
|
115
|
-
export function mentionedPrs(changelog: string): Set<number> {
|
|
116
|
-
const out = new Set<number>();
|
|
117
|
-
for (const m of changelog.matchAll(/(?:#|\/pull\/)(\d+)\b/g)) out.add(Number(m[1]));
|
|
118
|
-
return out;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function classifyOpenPr(pr: { number: number; title: string; html_url: string; draft?: boolean; head?: { ref?: string }; user?: { login?: string }; labels?: Array<{ name?: string }> }): OpenPr {
|
|
122
|
-
const title = pr.title;
|
|
123
|
-
const labels = (pr.labels ?? []).map((l) => l.name ?? "");
|
|
124
|
-
let kind: OpenPrKind;
|
|
125
|
-
if (pr.draft) kind = "draft";
|
|
126
|
-
else if (/^docs?(\([^)]*\))?!?:/i.test(title) || (pr.head?.ref ?? "").startsWith("docs/")) kind = "doc";
|
|
127
|
-
else if (labels.includes("type: dependencies") || /^deps?(\([^)]*\))?!?:/i.test(title) || (pr.user?.login ?? "").toLowerCase().startsWith("dependabot")) kind = "dependency";
|
|
128
|
-
else kind = "feature";
|
|
129
|
-
const v = kind === "doc" ? /\bv?(\d+\.\d+\.\d+)\b/.exec(title) : null;
|
|
130
|
-
return { number: pr.number, title, url: pr.html_url, kind, ...(v ? { version: v[1]! } : {}) };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function assessFreshness(version: string, changelog: string, commits: ReleaseCommitPr[], readme: string | undefined): Freshness {
|
|
134
|
-
const mentioned = mentionedPrs(changelog);
|
|
135
|
-
const missing = commits.filter((c) => !mentioned.has(c.number));
|
|
136
|
-
const missingUserFacing = missing.filter((c) => !INTERNAL_TYPES.has(c.type)).map(({ number, title }) => ({ number, title }));
|
|
137
|
-
const missingInternal = missing.filter((c) => INTERNAL_TYPES.has(c.type)).map(({ number, title }) => ({ number, title }));
|
|
138
|
-
const firstLine = changelog.split("\n", 1)[0] ?? "";
|
|
139
|
-
const headingMatchesVersion = new RegExp(`v?${version.replace(/\./g, "\\.")}\\b`).test(firstLine);
|
|
140
|
-
const readmeMentionsVersion = readme !== undefined && new RegExp(`Latest:\\s*v?${version.replace(/\./g, "\\.")}\\b`).test(readme);
|
|
141
|
-
const thin = changelog.length < 400 || !/^##\s+Overview/m.test(changelog);
|
|
142
|
-
let status: FreshnessStatus = "current";
|
|
143
|
-
if (!headingMatchesVersion) status = "version-drift";
|
|
144
|
-
else if (missingUserFacing.length > 0) status = "stale";
|
|
145
|
-
else if (thin) status = "thin";
|
|
146
|
-
else if (missingInternal.length > 0) status = "minor-gap";
|
|
147
|
-
return { status, expectedPrs: commits.length, missingUserFacing, missingInternal, headingMatchesVersion, readmeMentionsVersion };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** The release-docs decision table. */
|
|
151
|
-
export function decideVerdict(args: { hasChangelog: boolean; unknownVersion: boolean; freshness?: Freshness; openPrs: OpenPr[]; version: string | null }): { verdict: ReleaseVerdict; reason: string; docVersionMismatch?: boolean } {
|
|
152
|
-
if (args.unknownVersion) return { verdict: "inspect", reason: "the release PR title has no `release: vX.Y.Z` version" };
|
|
153
|
-
const f = args.freshness;
|
|
154
|
-
if (args.hasChangelog && f && (f.status === "current" || f.status === "minor-gap")) {
|
|
155
|
-
return { verdict: "done", reason: f.status === "minor-gap" ? `changelog covers every user-facing change; ${f.missingInternal.length} internal PR(s) not mentioned` : "changelog is current" };
|
|
156
|
-
}
|
|
157
|
-
const docs = args.openPrs.filter((p) => p.kind === "doc");
|
|
158
|
-
const features = args.openPrs.filter((p) => p.kind === "feature");
|
|
159
|
-
const others = args.openPrs.filter((p) => p.kind === "dependency" || p.kind === "draft");
|
|
160
|
-
const need = args.hasChangelog ? `changelog is ${f?.status ?? "out of date"}` : "no changelog file";
|
|
161
|
-
if (docs.length > 0) {
|
|
162
|
-
const mismatch = args.version !== null && docs.some((d) => d.version !== undefined && d.version !== args.version);
|
|
163
|
-
return { verdict: "in-flight", reason: `${need}; doc PR #${docs[0]!.number} is open`, ...(mismatch ? { docVersionMismatch: true } : {}) };
|
|
164
|
-
}
|
|
165
|
-
if (features.length > 0) return { verdict: "wait", reason: `${need}; ${features.length} feature/fix PR(s) still open against the release branch` };
|
|
166
|
-
if (others.length > 0) return { verdict: "gray", reason: `${need}; only dependency PR(s) or drafts are open (${others.length})` };
|
|
167
|
-
return { verdict: args.hasChangelog ? "update" : "write", reason: `${need}; nothing is open against the release branch` };
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function decodeContent(data: unknown): string | undefined {
|
|
171
|
-
const d = data as { content?: string; encoding?: string } | undefined;
|
|
172
|
-
if (!d || typeof d.content !== "string") return undefined;
|
|
173
|
-
return d.encoding === "base64" ? Buffer.from(d.content, "base64").toString("utf8") : d.content;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function getText(octokit: Octokit, budget: CallBudget, owner: string, repo: string, path: string, ref: string): Promise<string | undefined> {
|
|
177
|
-
try {
|
|
178
|
-
return decodeContent(await reqOne(octokit, budget, "GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path, ref }));
|
|
179
|
-
} catch (err) {
|
|
180
|
-
if (err instanceof RateLimited || err instanceof CallBudgetExhausted) throw err;
|
|
181
|
-
if ((err as { status?: number }).status === 404) return undefined;
|
|
182
|
-
throw err;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export async function analyzeRelease(
|
|
187
|
-
octokit: Octokit,
|
|
188
|
-
budget: CallBudget,
|
|
189
|
-
args: { owner: string; repo: string; pull: number; head: string; version: string | null; changelogPath?: string }
|
|
190
|
-
): Promise<ReleaseAnalysis> {
|
|
191
|
-
const { owner, repo } = args;
|
|
192
|
-
const open = await paginate<Parameters<typeof classifyOpenPr>[0]>(octokit, budget, "GET /repos/{owner}/{repo}/pulls", { owner, repo, state: "open", base: args.head }, 200);
|
|
193
|
-
const openPrs = open.map(classifyOpenPr);
|
|
194
|
-
|
|
195
|
-
let freshness: Freshness | undefined;
|
|
196
|
-
if (args.changelogPath && args.version) {
|
|
197
|
-
const commits = await paginate<{ commit?: { message?: string } }>(octokit, budget, "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits", { owner, repo, pull_number: args.pull }, 300);
|
|
198
|
-
const changelog = (await getText(octokit, budget, owner, repo, args.changelogPath, args.head)) ?? "";
|
|
199
|
-
const readme = await getText(octokit, budget, owner, repo, "README.md", args.head);
|
|
200
|
-
freshness = assessFreshness(args.version, changelog, releaseCommitPrs(commits.map((c) => c.commit?.message ?? "")), readme);
|
|
201
|
-
}
|
|
202
|
-
const decision = decideVerdict({ hasChangelog: Boolean(args.changelogPath), unknownVersion: args.version === null, freshness, openPrs, version: args.version });
|
|
203
|
-
return {
|
|
204
|
-
openPrs,
|
|
205
|
-
...(freshness ? { freshness } : {}),
|
|
206
|
-
...(decision.docVersionMismatch ? { docVersionMismatch: true } : {}),
|
|
207
|
-
verdict: decision.verdict,
|
|
208
|
-
reason: decision.reason
|
|
209
|
-
};
|
|
210
|
-
}
|