@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.
Files changed (107) hide show
  1. package/README.md +165 -41
  2. package/config.example.json +38 -9
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +76 -17
  5. package/deploy/install.mjs +171 -104
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +5864 -1809
  14. package/dist/index.mjs.map +4 -4
  15. package/package.json +18 -8
  16. package/.configs/fix-headers.json +0 -3
  17. package/.configs/vitest.config.mjs +0 -44
  18. package/.github/dependabot.yml +0 -111
  19. package/.github/workflows/branch-retention.yml +0 -44
  20. package/.github/workflows/bundle-size.yml +0 -52
  21. package/.github/workflows/ci.yml +0 -389
  22. package/.github/workflows/cla.yml +0 -66
  23. package/.github/workflows/codeql.yml +0 -87
  24. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  25. package/.github/workflows/dependabot-recreate.yml +0 -67
  26. package/.github/workflows/dependency-review.yml +0 -46
  27. package/.github/workflows/feature-pr.yml +0 -75
  28. package/.github/workflows/hotfix-redirector.yml +0 -67
  29. package/.github/workflows/hotfixes-release.yml +0 -70
  30. package/.github/workflows/labeler.yml +0 -48
  31. package/.github/workflows/master-commit-audit.yml +0 -56
  32. package/.github/workflows/member-auto-merge.yml +0 -75
  33. package/.github/workflows/next-release.yml +0 -76
  34. package/.github/workflows/next-reset.yml +0 -50
  35. package/.github/workflows/pr-notify.yml +0 -44
  36. package/.github/workflows/pr-title-normalizer.yml +0 -52
  37. package/.github/workflows/provenance.yml +0 -51
  38. package/.github/workflows/publish.yml +0 -146
  39. package/.github/workflows/release-merge.yml +0 -104
  40. package/.github/workflows/release-notify.yml +0 -41
  41. package/.github/workflows/scorecard.yml +0 -64
  42. package/.github/workflows/stale.yml +0 -50
  43. package/.github/workflows/tag-health.yml +0 -70
  44. package/.github/workflows/update-major-version-tags.yml +0 -94
  45. package/.github/workflows/v4-bootstrap.yml +0 -115
  46. package/.github/workflows/welcome.yml +0 -42
  47. package/docs/TOOLS.md +0 -347
  48. package/docs/agent-user-model.md +0 -89
  49. package/docs/changelog/v0/v0.2.0.md +0 -26
  50. package/docs/changelog/v0/v0.2.1.md +0 -23
  51. package/docs/changelog/v0/v0.2.2.md +0 -23
  52. package/docs/changelog/v0/v0.2.3.md +0 -33
  53. package/docs/changelog/v0/v0.2.4.md +0 -25
  54. package/docs/changelog/v0/v0.2.5.md +0 -29
  55. package/docs/changelog/v0/v0.3.0.md +0 -84
  56. package/docs/changelog/v0/v0.3.1.md +0 -30
  57. package/docs/changelog/v0/v0.3.2.md +0 -24
  58. package/docs/changelog/v0/v0.3.3.md +0 -28
  59. package/docs/changelog/v0/v0.4.0.md +0 -58
  60. package/docs/changelog/v0/v0.4.1.md +0 -33
  61. package/docs/changelog/v0/v0.4.2.md +0 -45
  62. package/docs/changelog/v0/v0.5.0.md +0 -39
  63. package/docs/changelog/v0/v0.5.1.md +0 -28
  64. package/docs/changelog/v0/v0.6.0.md +0 -52
  65. package/docs/changelog/v0/v0.6.1.md +0 -48
  66. package/docs/changelog/v0/v0.7.0.md +0 -71
  67. package/docs/deploy-architecture.md +0 -82
  68. package/src/appauth.ts +0 -56
  69. package/src/audit.ts +0 -34
  70. package/src/catalog.ts +0 -165
  71. package/src/config.ts +0 -130
  72. package/src/index.ts +0 -107
  73. package/src/paths.ts +0 -60
  74. package/src/router.ts +0 -258
  75. package/src/secrets.ts +0 -137
  76. package/src/server.ts +0 -91
  77. package/src/types.ts +0 -104
  78. package/src/upstream.ts +0 -85
  79. package/src/verbs/issue_labels.ts +0 -329
  80. package/src/verbs/link_issue.ts +0 -84
  81. package/src/verbs/release-analysis.ts +0 -210
  82. package/src/verbs/repo-tier.ts +0 -90
  83. package/src/verbs/report_ci_status.ts +0 -358
  84. package/src/verbs/report_release_prs.ts +0 -238
  85. package/src/verbs/report_security_alerts.ts +0 -442
  86. package/src/verbs/report_unlabeled.ts +0 -194
  87. package/src/verbs/rest-client.ts +0 -181
  88. package/src/verbs/review_thread.ts +0 -138
  89. package/tests/appauth.test.vitest.mts +0 -76
  90. package/tests/audit.test.vitest.mts +0 -48
  91. package/tests/catalog.test.vitest.mts +0 -180
  92. package/tests/config-merge.test.vitest.mts +0 -58
  93. package/tests/config.test.vitest.mts +0 -314
  94. package/tests/issue-labels.test.vitest.mts +0 -347
  95. package/tests/paths.test.vitest.mts +0 -52
  96. package/tests/release-prs.test.vitest.mts +0 -237
  97. package/tests/rest-client.test.vitest.mts +0 -115
  98. package/tests/router.test.vitest.mts +0 -399
  99. package/tests/run-vitest.mjs +0 -51
  100. package/tests/secret-store.test.vitest.mts +0 -106
  101. package/tests/secrets.test.vitest.mts +0 -155
  102. package/tests/security-alerts.test.vitest.mts +0 -328
  103. package/tests/server.test.vitest.mts +0 -20
  104. package/tests/upstream.test.vitest.mts +0 -91
  105. package/tests/verb-helpers.test.vitest.mts +0 -331
  106. package/tests/verbs.test.vitest.mts +0 -624
  107. package/tsconfig.json +0 -21
@@ -1,442 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/report_security_alerts.ts
5
- * @Date: 2026-10-03T05:10:00+00:00 (1791004200)
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-03T05:10:00+00:00 (1791004200)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import type { Octokit } from "@octokit/core";
17
- import { CallBudget, CallBudgetExhausted, RateLimited, listOwnerRepos, paginate, paginateAfter } from "./rest-client.ts";
18
- import { classifyRepo, type RepoTier } from "./repo-tier.ts";
19
-
20
- /**
21
- * Custom verb: report_security_alerts — every OPEN Dependabot, code-scanning (CodeQL)
22
- * and secret-scanning alert, for one repo or across everything an owner has.
23
- *
24
- * Why a custom verb: the hosted GitHub MCP's alert tools are single-repo, so "what is
25
- * open across the org" means one call per repo per alert type. This verb asks the
26
- * org-level endpoints first (one paginated call per alert type for the whole org) and
27
- * only falls back to a per-repo sweep for an alert type when the org endpoint isn't
28
- * available to the token (a user-owned account, or a PAT without org alert access).
29
- *
30
- * Alert state caveat: an alert's state tracks the default branch, so "open" here is
31
- * the default-branch view. An alert already fixed on a staging branch (`next`) but not
32
- * yet released still reads as open until the release lands.
33
- *
34
- * Secrets: only metadata is reported (type, validity, location of the alert) — the
35
- * secret value GitHub returns is deliberately dropped and never leaves this module.
36
- *
37
- * Fan-out safety mirrors report_unlabeled (see rest-client.ts): `maxRepos`,
38
- * `alertLimit` and `maxApiCalls` cap the work; hitting a cap or GitHub's rate limit
39
- * returns whatever was found with `truncated: true` and a reason.
40
- */
41
-
42
- export type AlertType = "dependabot" | "codeql" | "secrets";
43
-
44
- const ALL_TYPES: AlertType[] = ["dependabot", "codeql", "secrets"];
45
-
46
- export interface ReportSecurityAlertsInput {
47
- owner: string;
48
- /** Repo name; omit to cover every non-archived repo the owner has. */
49
- repo?: string;
50
- /** Which alert types to read. Default: all three. */
51
- types?: AlertType[];
52
- /** Safety cap on repos swept per-repo when the org endpoint is unavailable. Default 200, hard-capped at 500. */
53
- maxRepos?: number;
54
- /** Safety cap on alerts fetched per alert type (org mode) or per repo per type (per-repo mode). Default 500, hard-capped at 5000. */
55
- alertLimit?: number;
56
- /** Hard ceiling on total REST calls for this invocation. Default 700, hard-capped at 3000. */
57
- maxApiCalls?: number;
58
- /** Return only the per-repo roll-up (`byRepo`) and the counts, not the alert list. An org run is ~700 KB with the list; the roll-up is a few KB. */
59
- summaryOnly?: boolean;
60
- /** Keep only alerts at or above this severity (secret-scanning alerts have no severity and are always kept). */
61
- minSeverity?: "critical" | "high" | "medium" | "low";
62
- /** Keep only these repos (names, with or without the owner prefix). */
63
- repos?: string[];
64
- /** Add each repo's flow tier (`v4` / `dev-trunk` / `non-v4`, plus the raw signals) to `byRepo`. Costs about 3 REST calls per repo that has alerts. */
65
- classify?: boolean;
66
- }
67
-
68
- export interface RepoRollup {
69
- repo: string;
70
- total: number;
71
- dependabot: number;
72
- codeql: number;
73
- secrets: number;
74
- critical: number;
75
- high: number;
76
- medium: number;
77
- low: number;
78
- /** Only with `classify: true`; absent for a repo that could not be classified before a cap was hit. */
79
- tier?: RepoTier["tier"];
80
- tierSignals?: RepoTier["signals"];
81
- }
82
-
83
- export interface SecurityAlert {
84
- repo: string;
85
- type: AlertType;
86
- number: number;
87
- severity: string;
88
- summary: string;
89
- url: string;
90
- createdAt: string;
91
- /** Dependabot only. */
92
- package?: string;
93
- ecosystem?: string;
94
- manifest?: string;
95
- scope?: string;
96
- ghsa?: string;
97
- cve?: string;
98
- vulnerableRange?: string;
99
- patchedVersion?: string;
100
- /** Code scanning only. */
101
- rule?: string;
102
- tool?: string;
103
- location?: string;
104
- /** Secret scanning only. */
105
- secretType?: string;
106
- validity?: string;
107
- }
108
-
109
- export type SecurityTruncatedReason = "max-repos" | "max-alerts" | "max-api-calls" | "rate-limited";
110
-
111
- export interface ReportSecurityAlertsResult {
112
- /** Set only when a per-repo sweep ran. */
113
- scanned: { repos?: number };
114
- /** How each requested type was read: one org-level call set, or a per-repo sweep. */
115
- scope: Partial<Record<AlertType, "org" | "per-repo">>;
116
- /** Open alerts per type, keyed by severity (secrets are keyed `open`). */
117
- counts: Partial<Record<AlertType, Record<string, number>>>;
118
- /** One row per repo with at least one alert, worst first (criticals, then highs, then total). */
119
- byRepo: RepoRollup[];
120
- /** Empty when `summaryOnly` is set; see `alertsOmitted`. */
121
- alerts: SecurityAlert[];
122
- /** Set with `summaryOnly`: how many alerts were left out of `alerts`. */
123
- alertsOmitted?: number;
124
- /** Per-repo sweep only: repos where an alert type is switched off or not readable, per type. Omitted when none. */
125
- disabled?: Partial<Record<AlertType, number>>;
126
- truncated?: boolean;
127
- truncatedReason?: SecurityTruncatedReason;
128
- /** Only set when truncatedReason is "rate-limited" and GitHub sent a Retry-After. */
129
- retryAfterSeconds?: number;
130
- }
131
-
132
- const DEFAULT_MAX_REPOS = 200;
133
- const HARD_MAX_REPOS = 500;
134
- const DEFAULT_ALERT_LIMIT = 500;
135
- const HARD_ALERT_LIMIT = 5000;
136
- const DEFAULT_MAX_API_CALLS = 700;
137
- const HARD_MAX_API_CALLS = 3000;
138
-
139
- const SEVERITY_RANK: Record<string, number> = { critical: 0, high: 1, error: 1, medium: 2, moderate: 2, warning: 3, low: 4, note: 5 };
140
-
141
- interface RawRepository {
142
- name?: string;
143
- full_name?: string;
144
- }
145
-
146
- interface RawDependabot {
147
- number: number;
148
- html_url: string;
149
- created_at: string;
150
- repository?: RawRepository;
151
- dependency?: { package?: { ecosystem?: string; name?: string }; manifest_path?: string; scope?: string };
152
- security_advisory?: { ghsa_id?: string; cve_id?: string | null; summary?: string; severity?: string };
153
- security_vulnerability?: { severity?: string; vulnerable_version_range?: string; first_patched_version?: { identifier?: string } | null };
154
- }
155
-
156
- interface RawCodeql {
157
- number: number;
158
- html_url: string;
159
- created_at: string;
160
- repository?: RawRepository;
161
- rule?: { id?: string; severity?: string; security_severity_level?: string; description?: string };
162
- tool?: { name?: string };
163
- most_recent_instance?: { location?: { path?: string; start_line?: number } };
164
- }
165
-
166
- interface RawSecret {
167
- number: number;
168
- html_url: string;
169
- created_at: string;
170
- repository?: RawRepository;
171
- secret_type?: string;
172
- secret_type_display_name?: string;
173
- validity?: string;
174
- // `secret` (the value) is intentionally not modelled — see the module doc.
175
- }
176
-
177
- function normalizeDependabot(owner: string, repo: string, a: RawDependabot): SecurityAlert {
178
- const vuln = a.security_vulnerability;
179
- const out: SecurityAlert = {
180
- repo: a.repository?.full_name ?? `${owner}/${repo}`,
181
- type: "dependabot",
182
- number: a.number,
183
- severity: vuln?.severity ?? a.security_advisory?.severity ?? "unknown",
184
- summary: a.security_advisory?.summary ?? "",
185
- url: a.html_url,
186
- createdAt: a.created_at
187
- };
188
- if (a.dependency?.package?.name) out.package = a.dependency.package.name;
189
- if (a.dependency?.package?.ecosystem) out.ecosystem = a.dependency.package.ecosystem;
190
- if (a.dependency?.manifest_path) out.manifest = a.dependency.manifest_path;
191
- if (a.dependency?.scope) out.scope = a.dependency.scope;
192
- if (a.security_advisory?.ghsa_id) out.ghsa = a.security_advisory.ghsa_id;
193
- if (a.security_advisory?.cve_id) out.cve = a.security_advisory.cve_id;
194
- if (vuln?.vulnerable_version_range) out.vulnerableRange = vuln.vulnerable_version_range;
195
- if (vuln?.first_patched_version?.identifier) out.patchedVersion = vuln.first_patched_version.identifier;
196
- return out;
197
- }
198
-
199
- function normalizeCodeql(owner: string, repo: string, a: RawCodeql): SecurityAlert {
200
- const out: SecurityAlert = {
201
- repo: a.repository?.full_name ?? `${owner}/${repo}`,
202
- type: "codeql",
203
- number: a.number,
204
- severity: a.rule?.security_severity_level ?? a.rule?.severity ?? "unknown",
205
- summary: a.rule?.description ?? "",
206
- url: a.html_url,
207
- createdAt: a.created_at
208
- };
209
- if (a.rule?.id) out.rule = a.rule.id;
210
- if (a.tool?.name) out.tool = a.tool.name;
211
- const loc = a.most_recent_instance?.location;
212
- if (loc?.path) out.location = loc.start_line !== undefined ? `${loc.path}:${loc.start_line}` : loc.path;
213
- return out;
214
- }
215
-
216
- function normalizeSecret(owner: string, repo: string, a: RawSecret): SecurityAlert {
217
- const out: SecurityAlert = {
218
- repo: a.repository?.full_name ?? `${owner}/${repo}`,
219
- type: "secrets",
220
- number: a.number,
221
- severity: "open",
222
- summary: a.secret_type_display_name ?? a.secret_type ?? "",
223
- url: a.html_url,
224
- createdAt: a.created_at
225
- };
226
- if (a.secret_type) out.secretType = a.secret_type;
227
- if (a.validity) out.validity = a.validity;
228
- return out;
229
- }
230
-
231
- interface TypeSpec {
232
- orgRoute: string;
233
- repoRoute: string;
234
- /** Dependabot's endpoints are cursor-paginated; the other two take `page`. */
235
- cursor: boolean;
236
- normalize: (owner: string, repo: string, raw: never) => SecurityAlert;
237
- }
238
-
239
- const SPECS: Record<AlertType, TypeSpec> = {
240
- dependabot: {
241
- orgRoute: "GET /orgs/{owner}/dependabot/alerts",
242
- repoRoute: "GET /repos/{owner}/{repo}/dependabot/alerts",
243
- cursor: true,
244
- normalize: normalizeDependabot as TypeSpec["normalize"]
245
- },
246
- codeql: {
247
- orgRoute: "GET /orgs/{owner}/code-scanning/alerts",
248
- repoRoute: "GET /repos/{owner}/{repo}/code-scanning/alerts",
249
- cursor: false,
250
- normalize: normalizeCodeql as TypeSpec["normalize"]
251
- },
252
- secrets: {
253
- orgRoute: "GET /orgs/{owner}/secret-scanning/alerts",
254
- repoRoute: "GET /repos/{owner}/{repo}/secret-scanning/alerts",
255
- cursor: false,
256
- normalize: normalizeSecret as TypeSpec["normalize"]
257
- }
258
- };
259
-
260
- /** A 403/404 from an alert endpoint means "not available to this token / switched off here", not a failure. */
261
- function isUnavailable(err: unknown): boolean {
262
- const status = (err as { status?: number }).status;
263
- return status === 403 || status === 404;
264
- }
265
-
266
- async function fetchRaw(octokit: Octokit, budget: CallBudget, spec: TypeSpec, route: string, params: Record<string, unknown>, limit: number): Promise<unknown[]> {
267
- const query = { ...params, state: "open" };
268
- return spec.cursor ? paginateAfter<unknown>(octokit, budget, route, query, limit) : paginate<unknown>(octokit, budget, route, query, limit);
269
- }
270
-
271
- function severityRank(s: string): number {
272
- return SEVERITY_RANK[s.toLowerCase()] ?? 6;
273
- }
274
-
275
- export async function reportSecurityAlerts(octokit: Octokit, input: ReportSecurityAlertsInput): Promise<ReportSecurityAlertsResult> {
276
- const types = input.types && input.types.length > 0 ? ALL_TYPES.filter((t) => input.types!.includes(t)) : ALL_TYPES;
277
- const maxRepos = Math.min(input.maxRepos ?? DEFAULT_MAX_REPOS, HARD_MAX_REPOS);
278
- const alertLimit = Math.min(input.alertLimit ?? DEFAULT_ALERT_LIMIT, HARD_ALERT_LIMIT);
279
- const maxApiCalls = Math.min(input.maxApiCalls ?? DEFAULT_MAX_API_CALLS, HARD_MAX_API_CALLS);
280
- const budget = new CallBudget(maxApiCalls);
281
-
282
- const alerts: SecurityAlert[] = [];
283
- const scope: ReportSecurityAlertsResult["scope"] = {};
284
- const scanned: ReportSecurityAlertsResult["scanned"] = {};
285
- const disabled: Partial<Record<AlertType, number>> = {};
286
- let truncatedReason: SecurityTruncatedReason | undefined;
287
- let retryAfterSeconds: number | undefined;
288
-
289
- const addAll = (type: AlertType, owner: string, repo: string, raws: unknown[]) => {
290
- const spec = SPECS[type];
291
- for (const raw of raws) alerts.push(spec.normalize(owner, repo, raw as never));
292
- };
293
-
294
- try {
295
- // Which types still need a per-repo sweep (org endpoint unavailable, or a single repo was asked for).
296
- let sweep: AlertType[] = [];
297
- if (input.repo) {
298
- sweep = types;
299
- } else {
300
- for (const type of types) {
301
- try {
302
- const raws = await fetchRaw(octokit, budget, SPECS[type], SPECS[type].orgRoute, { owner: input.owner }, alertLimit);
303
- scope[type] = "org";
304
- if (raws.length >= alertLimit) truncatedReason ??= "max-alerts";
305
- addAll(type, input.owner, "", raws);
306
- } catch (err) {
307
- if (!isUnavailable(err)) throw err;
308
- sweep.push(type);
309
- }
310
- }
311
- }
312
-
313
- if (sweep.length > 0) {
314
- let repos: string[];
315
- if (input.repo) {
316
- repos = [input.repo];
317
- } else {
318
- // One past the cap, so "exactly maxRepos repos" can be told apart from "more than maxRepos".
319
- const listed = await listOwnerRepos(octokit, budget, input.owner, maxRepos + 1);
320
- if (listed.length > maxRepos) truncatedReason ??= "max-repos";
321
- repos = listed.slice(0, maxRepos);
322
- }
323
- scanned.repos = repos.length;
324
- for (const type of sweep) scope[type] = "per-repo";
325
-
326
- for (const repo of repos) {
327
- for (const type of sweep) {
328
- try {
329
- const raws = await fetchRaw(octokit, budget, SPECS[type], SPECS[type].repoRoute, { owner: input.owner, repo }, alertLimit);
330
- if (raws.length >= alertLimit) truncatedReason ??= "max-alerts";
331
- addAll(type, input.owner, repo, raws);
332
- } catch (err) {
333
- if (!isUnavailable(err)) throw err;
334
- disabled[type] = (disabled[type] ?? 0) + 1;
335
- }
336
- }
337
- }
338
- }
339
- } catch (err) {
340
- if (err instanceof RateLimited) {
341
- truncatedReason = "rate-limited";
342
- retryAfterSeconds = err.retryAfterSeconds;
343
- } else if (err instanceof CallBudgetExhausted) {
344
- truncatedReason = "max-api-calls";
345
- } else {
346
- throw err;
347
- }
348
- }
349
-
350
- // Filters run after fetching: the API has no cross-type severity or repo-list filter.
351
- const minRank = input.minSeverity ? { critical: 0, high: 1, medium: 2, low: 4 }[input.minSeverity] : undefined;
352
- const wanted = input.repos?.length ? new Set(input.repos.map((r) => r.replace(/^[^/]+\//, "").toLowerCase())) : undefined;
353
- for (let i = alerts.length - 1; i >= 0; i--) {
354
- const a = alerts[i]!;
355
- const name = a.repo.replace(/^[^/]+\//, "").toLowerCase();
356
- if (wanted && !wanted.has(name)) alerts.splice(i, 1);
357
- else if (minRank !== undefined && a.type !== "secrets" && severityRank(a.severity) > minRank) alerts.splice(i, 1);
358
- }
359
-
360
- alerts.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.repo.localeCompare(b.repo) || a.number - b.number);
361
-
362
- const counts: ReportSecurityAlertsResult["counts"] = {};
363
- for (const type of types) {
364
- if (scope[type] === undefined) continue; // never read (truncated before this type) — don't claim a zero count
365
- const byType: Record<string, number> = {};
366
- for (const a of alerts) if (a.type === type) byType[a.severity] = (byType[a.severity] ?? 0) + 1;
367
- counts[type] = byType;
368
- }
369
-
370
- const rollup = new Map<string, RepoRollup>();
371
- for (const a of alerts) {
372
- let row = rollup.get(a.repo);
373
- if (!row) {
374
- row = { repo: a.repo, total: 0, dependabot: 0, codeql: 0, secrets: 0, critical: 0, high: 0, medium: 0, low: 0 };
375
- rollup.set(a.repo, row);
376
- }
377
- row.total++;
378
- row[a.type]++;
379
- const r = severityRank(a.severity);
380
- if (r === 0) row.critical++;
381
- else if (r === 1) row.high++;
382
- else if (r === 2) row.medium++;
383
- else if (r >= 3 && r <= 5) row.low++;
384
- }
385
- const byRepo = [...rollup.values()].sort((a, b) => b.critical - a.critical || b.high - a.high || b.total - a.total || a.repo.localeCompare(b.repo));
386
-
387
- if (input.classify) {
388
- try {
389
- for (const row of byRepo) {
390
- const [owner, repo] = row.repo.includes("/") ? row.repo.split("/") : [input.owner, row.repo];
391
- const t = await classifyRepo(octokit, budget, owner!, repo!);
392
- row.tier = t.tier;
393
- row.tierSignals = t.signals;
394
- }
395
- } catch (err) {
396
- if (err instanceof RateLimited) {
397
- truncatedReason = "rate-limited";
398
- retryAfterSeconds = err.retryAfterSeconds;
399
- } else if (err instanceof CallBudgetExhausted) {
400
- truncatedReason = "max-api-calls";
401
- } else {
402
- throw err;
403
- }
404
- }
405
- }
406
-
407
- const omitted = input.summaryOnly ? alerts.length : undefined;
408
- return {
409
- scanned,
410
- scope,
411
- counts,
412
- byRepo,
413
- alerts: input.summaryOnly ? [] : alerts,
414
- ...(omitted !== undefined ? { alertsOmitted: omitted } : {}),
415
- ...(Object.keys(disabled).length > 0 ? { disabled } : {}),
416
- ...(truncatedReason ? { truncated: true, truncatedReason } : {}),
417
- ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {})
418
- };
419
- }
420
-
421
- /** JSON Schema advertised to the agent for report_security_alerts. */
422
- export const REPORT_SECURITY_ALERTS_SCHEMA = {
423
- type: "object" as const,
424
- required: ["owner"],
425
- additionalProperties: false,
426
- properties: {
427
- owner: { type: "string", description: "repository owner (org/user login) — selects the read credential" },
428
- repo: { type: "string", description: "repository name; omit to cover every non-archived repo this owner has" },
429
- types: {
430
- type: "array",
431
- items: { type: "string", enum: ["dependabot", "codeql", "secrets"] },
432
- description: "which alert types to read (default: all three)"
433
- },
434
- maxRepos: { type: "number", description: `safety cap on repos swept per-repo when the org-level endpoint is unavailable (default ${DEFAULT_MAX_REPOS}, hard cap ${HARD_MAX_REPOS})` },
435
- alertLimit: { type: "number", description: `safety cap on alerts fetched per type (org mode) or per repo per type (default ${DEFAULT_ALERT_LIMIT}, hard cap ${HARD_ALERT_LIMIT})` },
436
- maxApiCalls: { type: "number", description: `hard ceiling on total REST calls this invocation makes (default ${DEFAULT_MAX_API_CALLS}, hard cap ${HARD_MAX_API_CALLS})` },
437
- summaryOnly: { type: "boolean", description: "return only the per-repo roll-up (byRepo) and counts, not the alert list — a few KB instead of ~700 KB for a whole org", default: false },
438
- minSeverity: { type: "string", enum: ["critical", "high", "medium", "low"], description: "keep only alerts at or above this severity (secret-scanning alerts are always kept)" },
439
- repos: { type: "array", items: { type: "string" }, description: "keep only these repos (names, with or without the owner prefix)" },
440
- classify: { type: "boolean", description: "add each repo's flow tier (v4 / dev-trunk / non-v4) and the raw signals to byRepo; about 3 REST calls per repo with alerts", default: false }
441
- }
442
- };
@@ -1,194 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/report_unlabeled.ts
5
- * @Date: 2026-09-29T02:39:40+00:00 (1790649580)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- import type { Octokit } from "@octokit/core";
17
- import { CallBudget, CallBudgetExhausted, RateLimited, listOwnerRepos, paginate } from "./rest-client.ts";
18
-
19
- /**
20
- * Custom verb: report_unlabeled — issues/PRs with no labels, for one repo or every
21
- * repo an owner has.
22
- *
23
- * Why a custom verb: the hosted GitHub MCP has no "no labels" filter and no
24
- * cross-repo aggregation — `list_issues`/`list_pull_requests` are single-repo, and
25
- * finding the unlabeled subset means paging through everything and filtering
26
- * client-side. This composes plain REST reads (`octokit.request`) over the read PAT,
27
- * the same allowlist tier as everything it uses.
28
- *
29
- * Fan-out safety (see rest-client.ts for the shared rate-limit/budget mechanics):
30
- * scanning "every repo" can mean dozens of repos x hundreds of issues/PRs each.
31
- * - `maxRepos` caps how many repos get enumerated when `repo` is omitted.
32
- * - `perRepoLimit` caps items fetched per repo, per resource type (issues, pulls).
33
- * - `maxApiCalls` is a hard ceiling on total REST calls made by one invocation.
34
- * Hitting any cap — or GitHub's own rate limit — stops the scan and returns whatever
35
- * was found so far with `truncated: true`, rather than throwing partial work away.
36
- */
37
-
38
- export type Target = "issues" | "pulls" | "both";
39
- export type State = "open" | "closed" | "all";
40
-
41
- export interface ReportUnlabeledInput {
42
- owner: string;
43
- /** Repo name; omit to scan every non-archived repo visible to this owner's read PAT. */
44
- repo?: string;
45
- target?: Target;
46
- state?: State;
47
- /** Include draft PRs in the report (default: excluded — a draft isn't triage-ready). */
48
- includeDrafts?: boolean;
49
- /** Safety cap on repos scanned when `repo` is omitted. Default 50, hard-capped at 200. */
50
- maxRepos?: number;
51
- /** Safety cap on items fetched per repo, per resource type. Default 200, hard-capped at 1000. */
52
- perRepoLimit?: number;
53
- /** Hard ceiling on total REST calls for this invocation. Default 250, hard-capped at 1000. */
54
- maxApiCalls?: number;
55
- }
56
-
57
- export interface UnlabeledItem {
58
- repo: string;
59
- type: "issue" | "pull";
60
- number: number;
61
- title: string;
62
- url: string;
63
- createdAt: string;
64
- }
65
-
66
- export type TruncatedReason = "max-repos" | "max-api-calls" | "rate-limited";
67
-
68
- export interface ReportUnlabeledResult {
69
- scanned: { repos: number; issues: number; pulls: number };
70
- unlabeled: UnlabeledItem[];
71
- truncated?: boolean;
72
- truncatedReason?: TruncatedReason;
73
- /** Only set when truncatedReason is "rate-limited" and GitHub sent a Retry-After. */
74
- retryAfterSeconds?: number;
75
- }
76
-
77
- const DEFAULT_MAX_REPOS = 50;
78
- const HARD_MAX_REPOS = 200;
79
- const DEFAULT_PER_REPO_LIMIT = 200;
80
- const HARD_PER_REPO_LIMIT = 1000;
81
- const DEFAULT_MAX_API_CALLS = 250;
82
- const HARD_MAX_API_CALLS = 1000;
83
-
84
- interface RawIssueOrPull {
85
- number: number;
86
- title: string;
87
- html_url: string;
88
- created_at: string;
89
- labels: unknown[];
90
- /** Present only on items returned by the /issues endpoint that are actually PRs. */
91
- pull_request?: unknown;
92
- /** Present only on items returned by the /pulls endpoint. */
93
- draft?: boolean;
94
- }
95
-
96
- async function listUnlabeledIssues(
97
- octokit: Octokit,
98
- budget: CallBudget,
99
- owner: string,
100
- repo: string,
101
- state: State,
102
- limit: number
103
- ): Promise<{ scanned: number; unlabeled: UnlabeledItem[] }> {
104
- const raw = await paginate<RawIssueOrPull>(octokit, budget, "GET /repos/{owner}/{repo}/issues", { owner, repo, state }, limit);
105
- const issuesOnly = raw.filter((it) => !("pull_request" in it));
106
- const unlabeled = issuesOnly
107
- .filter((it) => it.labels.length === 0)
108
- .map((it) => toUnlabeledItem(owner, repo, "issue", it));
109
- return { scanned: issuesOnly.length, unlabeled };
110
- }
111
-
112
- async function listUnlabeledPulls(
113
- octokit: Octokit,
114
- budget: CallBudget,
115
- owner: string,
116
- repo: string,
117
- state: State,
118
- limit: number,
119
- includeDrafts: boolean
120
- ): Promise<{ scanned: number; unlabeled: UnlabeledItem[] }> {
121
- const raw = await paginate<RawIssueOrPull>(octokit, budget, "GET /repos/{owner}/{repo}/pulls", { owner, repo, state }, limit);
122
- const unlabeled = raw
123
- .filter((it) => includeDrafts || !it.draft)
124
- .filter((it) => it.labels.length === 0)
125
- .map((it) => toUnlabeledItem(owner, repo, "pull", it));
126
- return { scanned: raw.length, unlabeled };
127
- }
128
-
129
- function toUnlabeledItem(owner: string, repo: string, type: "issue" | "pull", it: RawIssueOrPull): UnlabeledItem {
130
- return { repo: `${owner}/${repo}`, type, number: it.number, title: it.title, url: it.html_url, createdAt: it.created_at };
131
- }
132
-
133
- export async function reportUnlabeled(octokit: Octokit, input: ReportUnlabeledInput): Promise<ReportUnlabeledResult> {
134
- const target = input.target ?? "both";
135
- const state = input.state ?? "open";
136
- const includeDrafts = input.includeDrafts ?? false;
137
- const maxRepos = Math.min(input.maxRepos ?? DEFAULT_MAX_REPOS, HARD_MAX_REPOS);
138
- const perRepoLimit = Math.min(input.perRepoLimit ?? DEFAULT_PER_REPO_LIMIT, HARD_PER_REPO_LIMIT);
139
- const maxApiCalls = Math.min(input.maxApiCalls ?? DEFAULT_MAX_API_CALLS, HARD_MAX_API_CALLS);
140
- const budget = new CallBudget(maxApiCalls);
141
-
142
- const unlabeled: UnlabeledItem[] = [];
143
- const scanned = { repos: 0, issues: 0, pulls: 0 };
144
-
145
- try {
146
- let repos: string[];
147
- if (input.repo) {
148
- repos = [input.repo];
149
- } else {
150
- repos = await listOwnerRepos(octokit, budget, input.owner, maxRepos);
151
- }
152
- scanned.repos = repos.length;
153
-
154
- for (const repo of repos) {
155
- if (target === "issues" || target === "both") {
156
- const out = await listUnlabeledIssues(octokit, budget, input.owner, repo, state, perRepoLimit);
157
- scanned.issues += out.scanned;
158
- unlabeled.push(...out.unlabeled);
159
- }
160
- if (target === "pulls" || target === "both") {
161
- const out = await listUnlabeledPulls(octokit, budget, input.owner, repo, state, perRepoLimit, includeDrafts);
162
- scanned.pulls += out.scanned;
163
- unlabeled.push(...out.unlabeled);
164
- }
165
- }
166
- } catch (err) {
167
- if (err instanceof RateLimited) {
168
- return { scanned, unlabeled, truncated: true, truncatedReason: "rate-limited", ...(err.retryAfterSeconds !== undefined ? { retryAfterSeconds: err.retryAfterSeconds } : {}) };
169
- }
170
- if (err instanceof CallBudgetExhausted) {
171
- return { scanned, unlabeled, truncated: true, truncatedReason: "max-api-calls" };
172
- }
173
- throw err;
174
- }
175
-
176
- return { scanned, unlabeled };
177
- }
178
-
179
- /** JSON Schema advertised to the agent for report_unlabeled. */
180
- export const REPORT_UNLABELED_SCHEMA = {
181
- type: "object" as const,
182
- required: ["owner"],
183
- additionalProperties: false,
184
- properties: {
185
- owner: { type: "string", description: "repository owner (org/user login) — selects the read credential" },
186
- repo: { type: "string", description: "repository name; omit to scan every non-archived repo visible to this owner's read PAT" },
187
- target: { type: "string", enum: ["issues", "pulls", "both"], description: "what to scan for missing labels", default: "both" },
188
- state: { type: "string", enum: ["open", "closed", "all"], description: "issue/PR state to scan", default: "open" },
189
- includeDrafts: { type: "boolean", description: "include draft PRs in the report (default: excluded)", default: false },
190
- maxRepos: { type: "number", description: `safety cap on repos scanned when repo is omitted (default ${DEFAULT_MAX_REPOS}, hard cap ${HARD_MAX_REPOS})` },
191
- perRepoLimit: { type: "number", description: `safety cap on items scanned per repo, per resource type (default ${DEFAULT_PER_REPO_LIMIT}, hard cap ${HARD_PER_REPO_LIMIT})` },
192
- maxApiCalls: { type: "number", description: `hard ceiling on total REST calls this invocation makes (default ${DEFAULT_MAX_API_CALLS}, hard cap ${HARD_MAX_API_CALLS})` }
193
- }
194
- };