@cldmv/gh-broker 0.0.0-stage → 0.7.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/.configs/fix-headers.json +3 -0
- package/.configs/vitest.config.mjs +44 -0
- package/.github/dependabot.yml +111 -0
- package/.github/workflows/branch-retention.yml +44 -0
- package/.github/workflows/bundle-size.yml +52 -0
- package/.github/workflows/ci.yml +389 -0
- package/.github/workflows/cla.yml +66 -0
- package/.github/workflows/codeql.yml +87 -0
- package/.github/workflows/dependabot-auto-merge.yml +62 -0
- package/.github/workflows/dependabot-recreate.yml +67 -0
- package/.github/workflows/dependency-review.yml +46 -0
- package/.github/workflows/feature-pr.yml +75 -0
- package/.github/workflows/hotfix-redirector.yml +67 -0
- package/.github/workflows/hotfixes-release.yml +70 -0
- package/.github/workflows/labeler.yml +48 -0
- package/.github/workflows/master-commit-audit.yml +56 -0
- package/.github/workflows/member-auto-merge.yml +75 -0
- package/.github/workflows/next-release.yml +76 -0
- package/.github/workflows/next-reset.yml +50 -0
- package/.github/workflows/pr-notify.yml +44 -0
- package/.github/workflows/pr-title-normalizer.yml +52 -0
- package/.github/workflows/provenance.yml +51 -0
- package/.github/workflows/publish.yml +146 -0
- package/.github/workflows/release-merge.yml +104 -0
- package/.github/workflows/release-notify.yml +41 -0
- package/.github/workflows/scorecard.yml +64 -0
- package/.github/workflows/stale.yml +50 -0
- package/.github/workflows/tag-health.yml +70 -0
- package/.github/workflows/update-major-version-tags.yml +94 -0
- package/.github/workflows/v4-bootstrap.yml +115 -0
- package/.github/workflows/welcome.yml +42 -0
- package/LICENSE +202 -0
- package/README.md +185 -2
- package/config.example.json +281 -0
- package/deploy/README.md +92 -0
- package/deploy/gh-broker.service +57 -0
- package/deploy/install.mjs +344 -0
- package/deploy/install.sh +29 -0
- package/deploy/lib/config-merge.mjs +56 -0
- package/deploy/lib/prompt.mjs +98 -0
- package/deploy/lib/secret-store.mjs +114 -0
- package/deploy/lib/smoke.mjs +137 -0
- package/deploy/lib/system.mjs +222 -0
- package/dist/index.mjs +22970 -0
- package/dist/index.mjs.map +7 -0
- package/docs/TOOLS.md +347 -0
- package/docs/agent-user-model.md +89 -0
- package/docs/changelog/v0/v0.2.0.md +26 -0
- package/docs/changelog/v0/v0.2.1.md +23 -0
- package/docs/changelog/v0/v0.2.2.md +23 -0
- package/docs/changelog/v0/v0.2.3.md +33 -0
- package/docs/changelog/v0/v0.2.4.md +25 -0
- package/docs/changelog/v0/v0.2.5.md +29 -0
- package/docs/changelog/v0/v0.3.0.md +84 -0
- package/docs/changelog/v0/v0.3.1.md +30 -0
- package/docs/changelog/v0/v0.3.2.md +24 -0
- package/docs/changelog/v0/v0.3.3.md +28 -0
- package/docs/changelog/v0/v0.4.0.md +58 -0
- package/docs/changelog/v0/v0.4.1.md +33 -0
- package/docs/changelog/v0/v0.4.2.md +45 -0
- package/docs/changelog/v0/v0.5.0.md +39 -0
- package/docs/changelog/v0/v0.5.1.md +28 -0
- package/docs/changelog/v0/v0.6.0.md +52 -0
- package/docs/changelog/v0/v0.6.1.md +48 -0
- package/docs/changelog/v0/v0.7.0.md +71 -0
- package/docs/deploy-architecture.md +82 -0
- package/package.json +71 -5
- package/src/appauth.ts +56 -0
- package/src/audit.ts +34 -0
- package/src/catalog.ts +165 -0
- package/src/config.ts +130 -0
- package/src/index.ts +107 -0
- package/src/paths.ts +60 -0
- package/src/router.ts +258 -0
- package/src/secrets.ts +137 -0
- package/src/server.ts +91 -0
- package/src/types.ts +104 -0
- package/src/upstream.ts +85 -0
- package/src/verbs/issue_labels.ts +329 -0
- package/src/verbs/link_issue.ts +84 -0
- package/src/verbs/release-analysis.ts +210 -0
- package/src/verbs/repo-tier.ts +90 -0
- package/src/verbs/report_ci_status.ts +358 -0
- package/src/verbs/report_release_prs.ts +238 -0
- package/src/verbs/report_security_alerts.ts +442 -0
- package/src/verbs/report_unlabeled.ts +194 -0
- package/src/verbs/rest-client.ts +181 -0
- package/src/verbs/review_thread.ts +138 -0
- package/tests/appauth.test.vitest.mts +76 -0
- package/tests/audit.test.vitest.mts +48 -0
- package/tests/catalog.test.vitest.mts +180 -0
- package/tests/config-merge.test.vitest.mts +58 -0
- package/tests/config.test.vitest.mts +314 -0
- package/tests/issue-labels.test.vitest.mts +347 -0
- package/tests/paths.test.vitest.mts +52 -0
- package/tests/release-prs.test.vitest.mts +237 -0
- package/tests/rest-client.test.vitest.mts +115 -0
- package/tests/router.test.vitest.mts +399 -0
- package/tests/run-vitest.mjs +51 -0
- package/tests/secret-store.test.vitest.mts +106 -0
- package/tests/secrets.test.vitest.mts +155 -0
- package/tests/security-alerts.test.vitest.mts +328 -0
- package/tests/server.test.vitest.mts +20 -0
- package/tests/upstream.test.vitest.mts +91 -0
- package/tests/verb-helpers.test.vitest.mts +331 -0
- package/tests/verbs.test.vitest.mts +624 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @Project: @cldmv/gh-broker
|
|
4
|
+
* @Filename: /src/verbs/report_release_prs.ts
|
|
5
|
+
* @Date: 2026-10-03T15:00:00+00:00 (1791039600)
|
|
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-03T15:00:00+00:00 (1791039600)
|
|
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, reqOne } from "./rest-client.ts";
|
|
18
|
+
import { analyzeRelease, type ReleaseAnalysis, type ReleaseVerdict } from "./release-analysis.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Custom verb: report_release_prs — every open release PR for an owner (or one repo),
|
|
22
|
+
* with whether its version's changelog file is already on the release branch.
|
|
23
|
+
*
|
|
24
|
+
* Built for mass updates: an agent sweeping a fleet needs to know which repos have a
|
|
25
|
+
* release PR waiting and which of those still lack the curated per-version changelog
|
|
26
|
+
* (`docs/changelog[s]/v<major>/vX.Y.Z.md`, the exact file the CLDMV release automation
|
|
27
|
+
* injects as the release-PR body). Per-repo `list_pull_requests` over every repo would
|
|
28
|
+
* be hundreds of calls; this uses ONE search call for the whole owner plus two cheap
|
|
29
|
+
* calls per release PR actually found.
|
|
30
|
+
*
|
|
31
|
+
* A "release PR" is an open PR carrying the `release` label (applied by the v4 release
|
|
32
|
+
* automation to both the `next → master` and `hotfixes → master` PRs). The version is
|
|
33
|
+
* read from the `release: vX.Y.Z - …` title.
|
|
34
|
+
*
|
|
35
|
+
* The changelog is looked up at the PR's head commit, not the default branch — the file
|
|
36
|
+
* lands on `next` before the release PR merges, so that is where it must exist.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export interface ReportReleasePrsInput {
|
|
40
|
+
owner: string;
|
|
41
|
+
/** Repo name; omit to search every repo this owner has. */
|
|
42
|
+
repo?: string;
|
|
43
|
+
/** Return only release PRs that are not ready (missing changelog or unreadable version). Default false. */
|
|
44
|
+
onlyMissing?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Also work out, per release PR, what is still open against its release branch, whether an existing
|
|
47
|
+
* changelog is actually up to date, and a verdict (done / in-flight / wait / gray / write / update / inspect).
|
|
48
|
+
* Costs about 5 more REST calls per release PR, so raise `maxApiCalls` for a whole org. Default false.
|
|
49
|
+
*/
|
|
50
|
+
analyze?: boolean;
|
|
51
|
+
/** Hard ceiling on total REST calls for this invocation. Default 300, hard-capped at 1000. */
|
|
52
|
+
maxApiCalls?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ReleasePrStatus = "ready" | "needs-changelog" | "unknown-version";
|
|
56
|
+
|
|
57
|
+
export interface ReleasePr {
|
|
58
|
+
repo: string;
|
|
59
|
+
pull: number;
|
|
60
|
+
url: string;
|
|
61
|
+
title: string;
|
|
62
|
+
/** Parsed from the `release: vX.Y.Z` title; null when the title has no version. */
|
|
63
|
+
version: string | null;
|
|
64
|
+
head: string;
|
|
65
|
+
base: string;
|
|
66
|
+
draft: boolean;
|
|
67
|
+
status: ReleasePrStatus;
|
|
68
|
+
/** The changelog path found, when `status` is "ready". */
|
|
69
|
+
changelogPath?: string;
|
|
70
|
+
/** The paths that were looked up, when `status` is "needs-changelog". */
|
|
71
|
+
checkedPaths?: string[];
|
|
72
|
+
/** Only with `analyze: true`. */
|
|
73
|
+
analysis?: ReleaseAnalysis;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ReleaseTruncatedReason = "max-results" | "incomplete-search" | "max-api-calls" | "rate-limited";
|
|
77
|
+
|
|
78
|
+
export interface ReportReleasePrsResult {
|
|
79
|
+
scanned: { releasePrs: number };
|
|
80
|
+
summary: { ready: number; needsChangelog: number; unknownVersion: number };
|
|
81
|
+
/** Only with `analyze: true`: how many release PRs landed on each verdict. */
|
|
82
|
+
verdicts?: Partial<Record<ReleaseVerdict, number>>;
|
|
83
|
+
releasePrs: ReleasePr[];
|
|
84
|
+
truncated?: boolean;
|
|
85
|
+
truncatedReason?: ReleaseTruncatedReason;
|
|
86
|
+
/** Only set when truncatedReason is "rate-limited" and GitHub sent a Retry-After. */
|
|
87
|
+
retryAfterSeconds?: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DEFAULT_MAX_API_CALLS = 300;
|
|
91
|
+
const HARD_MAX_API_CALLS = 1000;
|
|
92
|
+
const SEARCH_PAGE_SIZE = 100;
|
|
93
|
+
|
|
94
|
+
interface SearchHit {
|
|
95
|
+
number: number;
|
|
96
|
+
title: string;
|
|
97
|
+
html_url: string;
|
|
98
|
+
draft?: boolean;
|
|
99
|
+
repository_url: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface SearchResponse {
|
|
103
|
+
total_count: number;
|
|
104
|
+
incomplete_results: boolean;
|
|
105
|
+
items: SearchHit[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface RawPull {
|
|
109
|
+
head: { ref: string };
|
|
110
|
+
base: { ref: string };
|
|
111
|
+
draft?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Pulls `1.2.3` (or a prerelease form) out of a `release: v1.2.3 - …` title. */
|
|
115
|
+
export function parseReleaseVersion(title: string): string | null {
|
|
116
|
+
const m = /\brelease:\s*v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/i.exec(title);
|
|
117
|
+
return m ? m[1]! : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The places a version's changelog can live — `changelog` and `changelogs` are both in use. */
|
|
121
|
+
export function changelogPaths(version: string): string[] {
|
|
122
|
+
const major = version.split(".")[0]!;
|
|
123
|
+
return [`docs/changelog/v${major}/v${version}.md`, `docs/changelogs/v${major}/v${version}.md`];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function repoFromUrl(repositoryUrl: string): { owner: string; repo: string } {
|
|
127
|
+
const parts = repositoryUrl.split("/");
|
|
128
|
+
return { owner: parts[parts.length - 2]!, repo: parts[parts.length - 1]! };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** First of `paths` that exists at `ref`, or undefined. A 404 means "not there"; any other error propagates. */
|
|
132
|
+
async function findChangelog(octokit: Octokit, budget: CallBudget, owner: string, repo: string, ref: string, paths: string[]): Promise<string | undefined> {
|
|
133
|
+
for (const path of paths) {
|
|
134
|
+
try {
|
|
135
|
+
await reqOne(octokit, budget, "GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path, ref });
|
|
136
|
+
return path;
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err instanceof RateLimited || err instanceof CallBudgetExhausted) throw err;
|
|
139
|
+
if ((err as { status?: number }).status !== 404) throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function reportReleasePrs(octokit: Octokit, input: ReportReleasePrsInput): Promise<ReportReleasePrsResult> {
|
|
146
|
+
const maxApiCalls = Math.min(input.maxApiCalls ?? DEFAULT_MAX_API_CALLS, HARD_MAX_API_CALLS);
|
|
147
|
+
const budget = new CallBudget(maxApiCalls);
|
|
148
|
+
const scope = input.repo ? `repo:${input.owner}/${input.repo}` : `org:${input.owner}`;
|
|
149
|
+
|
|
150
|
+
const releasePrs: ReleasePr[] = [];
|
|
151
|
+
let truncatedReason: ReleaseTruncatedReason | undefined;
|
|
152
|
+
let retryAfterSeconds: number | undefined;
|
|
153
|
+
let hits = 0;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const search = await reqOne<SearchResponse>(octokit, budget, "GET /search/issues", {
|
|
157
|
+
q: `${scope} is:pr is:open label:release`,
|
|
158
|
+
per_page: SEARCH_PAGE_SIZE
|
|
159
|
+
});
|
|
160
|
+
hits = search.items.length;
|
|
161
|
+
if (search.total_count > search.items.length) truncatedReason = "max-results";
|
|
162
|
+
else if (search.incomplete_results) truncatedReason = "incomplete-search";
|
|
163
|
+
|
|
164
|
+
for (const hit of search.items) {
|
|
165
|
+
const { owner, repo } = repoFromUrl(hit.repository_url);
|
|
166
|
+
const pull = await reqOne<RawPull>(octokit, budget, "GET /repos/{owner}/{repo}/pulls/{pull_number}", { owner, repo, pull_number: hit.number });
|
|
167
|
+
const version = parseReleaseVersion(hit.title);
|
|
168
|
+
const base = {
|
|
169
|
+
repo: `${owner}/${repo}`,
|
|
170
|
+
pull: hit.number,
|
|
171
|
+
url: hit.html_url,
|
|
172
|
+
title: hit.title,
|
|
173
|
+
version,
|
|
174
|
+
head: pull.head.ref,
|
|
175
|
+
base: pull.base.ref,
|
|
176
|
+
draft: pull.draft ?? hit.draft ?? false
|
|
177
|
+
};
|
|
178
|
+
let entry: ReleasePr;
|
|
179
|
+
if (version === null) {
|
|
180
|
+
entry = { ...base, status: "unknown-version" };
|
|
181
|
+
} else {
|
|
182
|
+
const paths = changelogPaths(version);
|
|
183
|
+
const found = await findChangelog(octokit, budget, owner, repo, pull.head.ref, paths);
|
|
184
|
+
entry = found ? { ...base, status: "ready", changelogPath: found } : { ...base, status: "needs-changelog", checkedPaths: paths };
|
|
185
|
+
}
|
|
186
|
+
// Pushed before the analysis so a cap hit mid-analysis still reports this PR's status.
|
|
187
|
+
releasePrs.push(entry);
|
|
188
|
+
if (input.analyze) {
|
|
189
|
+
entry.analysis = await analyzeRelease(octokit, budget, { owner, repo, pull: hit.number, head: pull.head.ref, version, ...(entry.changelogPath ? { changelogPath: entry.changelogPath } : {}) });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err instanceof RateLimited) {
|
|
194
|
+
truncatedReason = "rate-limited";
|
|
195
|
+
retryAfterSeconds = err.retryAfterSeconds;
|
|
196
|
+
} else if (err instanceof CallBudgetExhausted) {
|
|
197
|
+
truncatedReason = "max-api-calls";
|
|
198
|
+
} else {
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const summary = {
|
|
204
|
+
ready: releasePrs.filter((p) => p.status === "ready").length,
|
|
205
|
+
needsChangelog: releasePrs.filter((p) => p.status === "needs-changelog").length,
|
|
206
|
+
unknownVersion: releasePrs.filter((p) => p.status === "unknown-version").length
|
|
207
|
+
};
|
|
208
|
+
let verdicts: Partial<Record<ReleaseVerdict, number>> | undefined;
|
|
209
|
+
if (input.analyze) {
|
|
210
|
+
verdicts = {};
|
|
211
|
+
for (const p of releasePrs) if (p.analysis) verdicts[p.analysis.verdict] = (verdicts[p.analysis.verdict] ?? 0) + 1;
|
|
212
|
+
}
|
|
213
|
+
// With analysis, "missing" means "not done": a ready-but-stale changelog still needs work.
|
|
214
|
+
const listed = input.onlyMissing ? releasePrs.filter((p) => (p.analysis ? p.analysis.verdict !== "done" : p.status !== "ready")) : releasePrs;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
scanned: { releasePrs: hits },
|
|
218
|
+
summary,
|
|
219
|
+
...(verdicts ? { verdicts } : {}),
|
|
220
|
+
releasePrs: listed,
|
|
221
|
+
...(truncatedReason ? { truncated: true, truncatedReason } : {}),
|
|
222
|
+
...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {})
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** JSON Schema advertised to the agent for report_release_prs. */
|
|
227
|
+
export const REPORT_RELEASE_PRS_SCHEMA = {
|
|
228
|
+
type: "object" as const,
|
|
229
|
+
required: ["owner"],
|
|
230
|
+
additionalProperties: false,
|
|
231
|
+
properties: {
|
|
232
|
+
owner: { type: "string", description: "repository owner (org/user login) — selects the read credential" },
|
|
233
|
+
repo: { type: "string", description: "repository name; omit to search every repo this owner has" },
|
|
234
|
+
onlyMissing: { type: "boolean", description: "return only release PRs that still need work (no changelog, unreadable version, or — with analyze — a verdict other than done); summary counts still cover all", default: false },
|
|
235
|
+
analyze: { type: "boolean", description: "also compute, per release PR: open PRs against its release branch (doc / dependency / feature / draft), whether an existing changelog is up to date (missing PRs, version heading, README), and a verdict (done / in-flight / wait / gray / write / update / inspect). ~5 extra calls per release PR — raise maxApiCalls for a whole org", default: false },
|
|
236
|
+
maxApiCalls: { type: "number", description: `hard ceiling on total REST calls this invocation makes (default ${DEFAULT_MAX_API_CALLS}, hard cap ${HARD_MAX_API_CALLS})` }
|
|
237
|
+
}
|
|
238
|
+
};
|
|
@@ -0,0 +1,442 @@
|
|
|
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
|
+
};
|