@cldmv/gh-broker 0.8.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 (92) hide show
  1. package/README.md +40 -10
  2. package/config.example.json +17 -1
  3. package/deploy/README.md +3 -2
  4. package/deploy/install.mjs +5 -3
  5. package/dist/index.mjs +4261 -1642
  6. package/dist/index.mjs.map +4 -4
  7. package/package.json +12 -2
  8. package/docs/TOOLS.md +0 -538
  9. package/docs/agent-user-model.md +0 -89
  10. package/docs/changelog/v0/v0.2.0.md +0 -26
  11. package/docs/changelog/v0/v0.2.1.md +0 -23
  12. package/docs/changelog/v0/v0.2.2.md +0 -23
  13. package/docs/changelog/v0/v0.2.3.md +0 -33
  14. package/docs/changelog/v0/v0.2.4.md +0 -25
  15. package/docs/changelog/v0/v0.2.5.md +0 -29
  16. package/docs/changelog/v0/v0.3.0.md +0 -84
  17. package/docs/changelog/v0/v0.3.1.md +0 -30
  18. package/docs/changelog/v0/v0.3.2.md +0 -24
  19. package/docs/changelog/v0/v0.3.3.md +0 -28
  20. package/docs/changelog/v0/v0.4.0.md +0 -58
  21. package/docs/changelog/v0/v0.4.1.md +0 -33
  22. package/docs/changelog/v0/v0.4.2.md +0 -45
  23. package/docs/changelog/v0/v0.5.0.md +0 -39
  24. package/docs/changelog/v0/v0.5.1.md +0 -28
  25. package/docs/changelog/v0/v0.6.0.md +0 -52
  26. package/docs/changelog/v0/v0.6.1.md +0 -48
  27. package/docs/changelog/v0/v0.7.0.md +0 -71
  28. package/docs/changelog/v0/v0.8.0.md +0 -115
  29. package/docs/deploy-architecture.md +0 -84
  30. package/src/appauth.ts +0 -56
  31. package/src/audit.ts +0 -34
  32. package/src/availability.ts +0 -42
  33. package/src/catalog.ts +0 -182
  34. package/src/config.ts +0 -157
  35. package/src/index.ts +0 -107
  36. package/src/paths.ts +0 -60
  37. package/src/router.ts +0 -299
  38. package/src/secrets.ts +0 -167
  39. package/src/server.ts +0 -91
  40. package/src/types.ts +0 -128
  41. package/src/upstream.ts +0 -85
  42. package/src/verbs/ghcr/archive.ts +0 -314
  43. package/src/verbs/ghcr/config.ts +0 -142
  44. package/src/verbs/ghcr/jobs.ts +0 -141
  45. package/src/verbs/ghcr/push.ts +0 -192
  46. package/src/verbs/ghcr/registry.ts +0 -275
  47. package/src/verbs/ghcr/tar.ts +0 -203
  48. package/src/verbs/ghcr_push.ts +0 -264
  49. package/src/verbs/issue_labels.ts +0 -329
  50. package/src/verbs/link_issue.ts +0 -84
  51. package/src/verbs/npm-registry.ts +0 -168
  52. package/src/verbs/npm-tarball.ts +0 -76
  53. package/src/verbs/npm_scaffold.ts +0 -325
  54. package/src/verbs/release-analysis.ts +0 -212
  55. package/src/verbs/repo-tier.ts +0 -90
  56. package/src/verbs/report_ci_status.ts +0 -358
  57. package/src/verbs/report_release_prs.ts +0 -238
  58. package/src/verbs/report_security_alerts.ts +0 -442
  59. package/src/verbs/report_unlabeled.ts +0 -194
  60. package/src/verbs/rest-client.ts +0 -181
  61. package/src/verbs/review_thread.ts +0 -138
  62. package/tests/appauth.test.vitest.mts +0 -76
  63. package/tests/audit.test.vitest.mts +0 -48
  64. package/tests/catalog.test.vitest.mts +0 -234
  65. package/tests/config-merge.test.vitest.mts +0 -325
  66. package/tests/config.test.vitest.mts +0 -388
  67. package/tests/credentials.test.vitest.mts +0 -127
  68. package/tests/ghcr-archive.test.vitest.mts +0 -439
  69. package/tests/ghcr-config.test.vitest.mts +0 -96
  70. package/tests/ghcr-push.test.vitest.mts +0 -526
  71. package/tests/ghcr-registry.test.vitest.mts +0 -206
  72. package/tests/helpers/ghcr-fixtures.mts +0 -373
  73. package/tests/install-args.test.vitest.mts +0 -152
  74. package/tests/issue-labels.test.vitest.mts +0 -362
  75. package/tests/npm-scaffold.test.vitest.mts +0 -479
  76. package/tests/npm-setup.test.vitest.mts +0 -196
  77. package/tests/paths.test.vitest.mts +0 -57
  78. package/tests/prompt.test.vitest.mts +0 -128
  79. package/tests/release-prs.test.vitest.mts +0 -247
  80. package/tests/rest-client.test.vitest.mts +0 -115
  81. package/tests/router.test.vitest.mts +0 -505
  82. package/tests/run-vitest.mjs +0 -51
  83. package/tests/secret-store.test.vitest.mts +0 -167
  84. package/tests/secrets.test.vitest.mts +0 -208
  85. package/tests/security-alerts.test.vitest.mts +0 -338
  86. package/tests/server.test.vitest.mts +0 -200
  87. package/tests/smoke.test.vitest.mts +0 -177
  88. package/tests/system.test.vitest.mts +0 -355
  89. package/tests/upstream.test.vitest.mts +0 -91
  90. package/tests/verb-helpers.test.vitest.mts +0 -377
  91. package/tests/verbs.test.vitest.mts +0 -624
  92. package/tsconfig.json +0 -21
@@ -1,212 +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-31T23:26:36-07:00 (1793514396)
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
- // String.prototype.split always returns at least one element, so the "" fallback is unreachable; it only satisfies noUncheckedIndexedAccess.
139
- /* v8 ignore next */
140
- const firstLine = changelog.split("\n", 1)[0] ?? "";
141
- const headingMatchesVersion = new RegExp(`v?${version.replace(/\./g, "\\.")}\\b`).test(firstLine);
142
- const readmeMentionsVersion = readme !== undefined && new RegExp(`Latest:\\s*v?${version.replace(/\./g, "\\.")}\\b`).test(readme);
143
- const thin = changelog.length < 400 || !/^##\s+Overview/m.test(changelog);
144
- let status: FreshnessStatus = "current";
145
- if (!headingMatchesVersion) status = "version-drift";
146
- else if (missingUserFacing.length > 0) status = "stale";
147
- else if (thin) status = "thin";
148
- else if (missingInternal.length > 0) status = "minor-gap";
149
- return { status, expectedPrs: commits.length, missingUserFacing, missingInternal, headingMatchesVersion, readmeMentionsVersion };
150
- }
151
-
152
- /** The release-docs decision table. */
153
- export function decideVerdict(args: { hasChangelog: boolean; unknownVersion: boolean; freshness?: Freshness; openPrs: OpenPr[]; version: string | null }): { verdict: ReleaseVerdict; reason: string; docVersionMismatch?: boolean } {
154
- if (args.unknownVersion) return { verdict: "inspect", reason: "the release PR title has no `release: vX.Y.Z` version" };
155
- const f = args.freshness;
156
- if (args.hasChangelog && f && (f.status === "current" || f.status === "minor-gap")) {
157
- 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" };
158
- }
159
- const docs = args.openPrs.filter((p) => p.kind === "doc");
160
- const features = args.openPrs.filter((p) => p.kind === "feature");
161
- const others = args.openPrs.filter((p) => p.kind === "dependency" || p.kind === "draft");
162
- const need = args.hasChangelog ? `changelog is ${f?.status ?? "out of date"}` : "no changelog file";
163
- if (docs.length > 0) {
164
- const mismatch = args.version !== null && docs.some((d) => d.version !== undefined && d.version !== args.version);
165
- return { verdict: "in-flight", reason: `${need}; doc PR #${docs[0]!.number} is open`, ...(mismatch ? { docVersionMismatch: true } : {}) };
166
- }
167
- if (features.length > 0) return { verdict: "wait", reason: `${need}; ${features.length} feature/fix PR(s) still open against the release branch` };
168
- if (others.length > 0) return { verdict: "gray", reason: `${need}; only dependency PR(s) or drafts are open (${others.length})` };
169
- return { verdict: args.hasChangelog ? "update" : "write", reason: `${need}; nothing is open against the release branch` };
170
- }
171
-
172
- function decodeContent(data: unknown): string | undefined {
173
- const d = data as { content?: string; encoding?: string } | undefined;
174
- if (!d || typeof d.content !== "string") return undefined;
175
- return d.encoding === "base64" ? Buffer.from(d.content, "base64").toString("utf8") : d.content;
176
- }
177
-
178
- async function getText(octokit: Octokit, budget: CallBudget, owner: string, repo: string, path: string, ref: string): Promise<string | undefined> {
179
- try {
180
- return decodeContent(await reqOne(octokit, budget, "GET /repos/{owner}/{repo}/contents/{path}", { owner, repo, path, ref }));
181
- } catch (err) {
182
- if (err instanceof RateLimited || err instanceof CallBudgetExhausted) throw err;
183
- if ((err as { status?: number }).status === 404) return undefined;
184
- throw err;
185
- }
186
- }
187
-
188
- export async function analyzeRelease(
189
- octokit: Octokit,
190
- budget: CallBudget,
191
- args: { owner: string; repo: string; pull: number; head: string; version: string | null; changelogPath?: string }
192
- ): Promise<ReleaseAnalysis> {
193
- const { owner, repo } = args;
194
- const open = await paginate<Parameters<typeof classifyOpenPr>[0]>(octokit, budget, "GET /repos/{owner}/{repo}/pulls", { owner, repo, state: "open", base: args.head }, 200);
195
- const openPrs = open.map(classifyOpenPr);
196
-
197
- let freshness: Freshness | undefined;
198
- if (args.changelogPath && args.version) {
199
- const commits = await paginate<{ commit?: { message?: string } }>(octokit, budget, "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits", { owner, repo, pull_number: args.pull }, 300);
200
- const changelog = (await getText(octokit, budget, owner, repo, args.changelogPath, args.head)) ?? "";
201
- const readme = await getText(octokit, budget, owner, repo, "README.md", args.head);
202
- freshness = assessFreshness(args.version, changelog, releaseCommitPrs(commits.map((c) => c.commit?.message ?? "")), readme);
203
- }
204
- const decision = decideVerdict({ hasChangelog: Boolean(args.changelogPath), unknownVersion: args.version === null, freshness, openPrs, version: args.version });
205
- return {
206
- openPrs,
207
- ...(freshness ? { freshness } : {}),
208
- ...(decision.docVersionMismatch ? { docVersionMismatch: true } : {}),
209
- verdict: decision.verdict,
210
- reason: decision.reason
211
- };
212
- }
@@ -1,90 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/repo-tier.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
- * Classifies a repo into the CLDMV flow tiers the maintenance skills care about.
18
- *
19
- * - `v4` — carries the v4 release workflow and is a normal next → master staging-flow repo.
20
- * - `dev-trunk` — a v4 repo whose real code lives on a persistent `dev` branch while the default
21
- * branch / `next` hold only the scaffold.
22
- * - `non-v4` — no v4 release workflow on the default branch (even if `next`/`hotfixes` exist).
23
- *
24
- * Detection is structural, from three cheap reads, so a skill doesn't have to walk branches and
25
- * workflow folders itself. It is a signal, not a verdict: a stale `dev` branch on a repo whose
26
- * `next` happens to equal `master` right after a release looks like a dev-trunk repo, so the
27
- * raw `signals` are returned for the caller to cross-check.
28
- */
29
-
30
- import type { Octokit } from "@octokit/core";
31
- import { CallBudget, CallBudgetExhausted, RateLimited, reqOne } from "./rest-client.ts";
32
-
33
- export type RepoTierName = "v4" | "dev-trunk" | "non-v4";
34
-
35
- export interface RepoTier {
36
- tier: RepoTierName;
37
- signals: {
38
- /** `.github/workflows/next-release.yml` exists on the default branch. */
39
- v4Workflow: boolean;
40
- defaultBranch?: string;
41
- /** `next` branch exists. */
42
- next?: boolean;
43
- /** `dev` branch exists. */
44
- dev?: boolean;
45
- /** `next` is at the same commit as the default branch (nothing queued; scaffold-only when dev exists). */
46
- nextEqualsDefault?: boolean;
47
- /** `dev` is at a different commit than the default branch. */
48
- devDiffersFromDefault?: boolean;
49
- };
50
- }
51
-
52
- async function getOrUndefined<T>(octokit: Octokit, budget: CallBudget, route: string, params: Record<string, unknown>): Promise<T | undefined> {
53
- try {
54
- return await reqOne<T>(octokit, budget, route, params);
55
- } catch (err) {
56
- if (err instanceof RateLimited || err instanceof CallBudgetExhausted) throw err;
57
- if ((err as { status?: number }).status === 404) return undefined;
58
- throw err;
59
- }
60
- }
61
-
62
- export async function classifyRepo(octokit: Octokit, budget: CallBudget, owner: string, repo: string): Promise<RepoTier> {
63
- const info = await getOrUndefined<{ default_branch?: string }>(octokit, budget, "GET /repos/{owner}/{repo}", { owner, repo });
64
- const defaultBranch = info?.default_branch;
65
- const workflow = await getOrUndefined<unknown>(octokit, budget, "GET /repos/{owner}/{repo}/contents/{path}", {
66
- owner,
67
- repo,
68
- path: ".github/workflows/next-release.yml",
69
- ...(defaultBranch ? { ref: defaultBranch } : {})
70
- });
71
- const v4Workflow = workflow !== undefined;
72
- if (!v4Workflow) return { tier: "non-v4", signals: { v4Workflow, ...(defaultBranch ? { defaultBranch } : {}) } };
73
-
74
- const sha = async (branch: string): Promise<string | undefined> => {
75
- const b = await getOrUndefined<{ commit?: { sha?: string } }>(octokit, budget, "GET /repos/{owner}/{repo}/branches/{branch}", { owner, repo, branch });
76
- return b?.commit?.sha;
77
- };
78
- const [defaultSha, nextSha, devSha] = await Promise.all([defaultBranch ? sha(defaultBranch) : undefined, sha("next"), sha("dev")]);
79
- const signals: RepoTier["signals"] = {
80
- v4Workflow,
81
- ...(defaultBranch ? { defaultBranch } : {}),
82
- next: nextSha !== undefined,
83
- dev: devSha !== undefined,
84
- ...(nextSha && defaultSha ? { nextEqualsDefault: nextSha === defaultSha } : {}),
85
- ...(devSha && defaultSha ? { devDiffersFromDefault: devSha !== defaultSha } : {})
86
- };
87
- const scaffoldOnly = nextSha === undefined || nextSha === defaultSha;
88
- const tier: RepoTierName = devSha !== undefined && signals.devDiffersFromDefault && scaffoldOnly ? "dev-trunk" : "v4";
89
- return { tier, signals };
90
- }
@@ -1,358 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /src/verbs/report_ci_status.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, paginate, reqOne } from "./rest-client.ts";
18
-
19
- /**
20
- * Custom verb: report_ci_status — CI health for a PR (or every open PR in a repo),
21
- * at a verbosity the caller chooses: counts only by default, escalating to
22
- * run/job detail, failing-step detail, and finally a tail of the failing job's log.
23
- *
24
- * Run/job/step classification is built on the GitHub Actions REST API
25
- * (`/actions/runs`, `/actions/runs/{id}/jobs`), NOT the Checks API
26
- * (`get_check_runs`, already proxied via `pull_request_read`) — verified live that
27
- * the configured read PAT has Actions access but not Checks access (fine-grained
28
- * PAT permissions are separate scopes). Building on Actions avoids needing a PAT
29
- * permission change to ship this.
30
- *
31
- * Tier 3's log fetch instead goes through the hosted MCP's own `get_job_logs` tool
32
- * (`fetchFailedJobLogs`, injected by the caller — router.ts has the upstream/token
33
- * this verb doesn't) rather than a hand-rolled REST call: `get_job_logs` already
34
- * does exactly this (`run_id` + `failed_only: true` fetches every failing job in a
35
- * run in one call, with its own `tail_lines`), so there's no reason to duplicate
36
- * it. One call per failing RUN, not per failing job. This means tier-3 log fetches
37
- * are NOT counted by the CallBudget below (that only tracks direct `octokit.request`
38
- * calls and their rate-limit headers) — an accepted gap, not an oversight: an
39
- * upstream-forwarded MCP call exposes no rate-limit headers to inspect, and the
40
- * fan-out is already bounded (one call per ACTUALLY-failing run, not per PR).
41
- *
42
- * Classification (run/job/step all share this): `status !== "completed"` is
43
- * "pending"; a completed run/job/step is "failed" unless its conclusion is
44
- * success/neutral/skipped (those three are non-blocking outcomes, not failures).
45
- *
46
- * Fan-out: verbosity >= 1 detail (job/step calls, and >= 3's log fetch) is only
47
- * ever spent on runs/jobs that are ACTUALLY failing — a healthy PR costs exactly
48
- * one call regardless of verbosity. When `pull` is omitted, only PRs with at least
49
- * one failure are included in the result (see rest-client.ts for the shared
50
- * call-budget + rate-limit-floor mechanics, identical to report_unlabeled's).
51
- */
52
-
53
- export type Verbosity = 0 | 1 | 2 | 3;
54
-
55
- /**
56
- * Fetches every failing job's log (already tailed to `tailLines`) for one workflow
57
- * run, keyed by job id — the shape `get_job_logs` returns with `failed_only: true`.
58
- * Injected by the caller (router.ts), which holds the upstream/token this verb
59
- * doesn't. Absent (or a lookup miss for a given job) just means no `logTail` on
60
- * that failure — best-effort, never a hard failure of the whole report.
61
- */
62
- export type FailedJobLogsFetcher = (runId: number, tailLines: number) => Promise<Map<number, string>>;
63
-
64
- export interface ReportCiStatusInput {
65
- owner: string;
66
- repo: string;
67
- /** PR number; omit to scan every open PR in the repo. */
68
- pull?: number;
69
- verbosity?: Verbosity;
70
- /** Tier-3 only: tail lines of the failing job's log. Default 200, hard cap 2000. */
71
- logLines?: number;
72
- /** Safety cap on open PRs scanned when `pull` is omitted. Default 50, hard cap 200. */
73
- maxPulls?: number;
74
- /** Hard ceiling on total REST calls for this invocation. Default 250, hard cap 1000. */
75
- maxApiCalls?: number;
76
- }
77
-
78
- export interface CiSummary {
79
- passed: number;
80
- failed: number;
81
- pending: number;
82
- }
83
-
84
- export interface CiStepFailure {
85
- name: string;
86
- number: number;
87
- conclusion: string;
88
- durationMs: number;
89
- }
90
-
91
- export interface CiFailure {
92
- workflow: string;
93
- job: string;
94
- runId: number;
95
- runUrl: string;
96
- conclusion: string;
97
- /** Tier >= 2: the job's failing steps. */
98
- steps?: CiStepFailure[];
99
- /**
100
- * Tier 3: tail of the JOB's combined log (not per-step — GitHub's job log is one
101
- * concatenated stream across all steps; reliably slicing it per-step would need
102
- * parsing timestamp/group markers, which is fragile. The job-level tail almost
103
- * always contains the actual failure output anyway.
104
- */
105
- logTail?: string;
106
- }
107
-
108
- export type TruncatedReason = "max-pulls" | "max-api-calls" | "rate-limited";
109
-
110
- interface Truncation {
111
- truncated: true;
112
- truncatedReason: TruncatedReason;
113
- retryAfterSeconds?: number;
114
- }
115
-
116
- export interface CiStatusResult extends Partial<Truncation> {
117
- pull: number;
118
- summary: CiSummary;
119
- failures?: CiFailure[];
120
- }
121
-
122
- export interface ReportCiStatusResult extends Partial<Truncation> {
123
- scanned: { pulls: number };
124
- results: CiStatusResult[];
125
- }
126
-
127
- const DEFAULT_LOG_LINES = 200;
128
- const HARD_LOG_LINES = 2000;
129
- const DEFAULT_MAX_PULLS = 50;
130
- const HARD_MAX_PULLS = 200;
131
- const DEFAULT_MAX_API_CALLS = 250;
132
- const HARD_MAX_API_CALLS = 1000;
133
-
134
- interface RawRun {
135
- id: number;
136
- name: string;
137
- status: string;
138
- conclusion: string | null;
139
- html_url: string;
140
- }
141
-
142
- interface RawStep {
143
- name: string;
144
- status: string;
145
- conclusion: string | null;
146
- number: number;
147
- started_at: string | null;
148
- completed_at: string | null;
149
- }
150
-
151
- interface RawJob {
152
- id: number;
153
- name: string;
154
- status: string;
155
- conclusion: string | null;
156
- html_url: string;
157
- steps?: RawStep[];
158
- }
159
-
160
- type Outcome = "passed" | "failed" | "pending";
161
-
162
- /** Non-blocking completed conclusions — everything else completed counts as failed. */
163
- const NON_FAILING_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
164
-
165
- function classify(status: string, conclusion: string | null): Outcome {
166
- if (status !== "completed") return "pending";
167
- return conclusion !== null && NON_FAILING_CONCLUSIONS.has(conclusion) ? "passed" : "failed";
168
- }
169
-
170
- function stepDurationMs(step: RawStep): number {
171
- if (!step.started_at || !step.completed_at) return 0;
172
- return new Date(step.completed_at).getTime() - new Date(step.started_at).getTime();
173
- }
174
-
175
- function truncationFrom(err: unknown): Truncation | undefined {
176
- if (err instanceof RateLimited) {
177
- return { truncated: true, truncatedReason: "rate-limited", ...(err.retryAfterSeconds !== undefined ? { retryAfterSeconds: err.retryAfterSeconds } : {}) };
178
- }
179
- if (err instanceof CallBudgetExhausted) return { truncated: true, truncatedReason: "max-api-calls" };
180
- return undefined;
181
- }
182
-
183
- async function computeForPull(
184
- octokit: Octokit,
185
- budget: CallBudget,
186
- owner: string,
187
- repo: string,
188
- pullNumber: number,
189
- headSha: string,
190
- verbosity: Verbosity,
191
- logLines: number,
192
- fetchFailedJobLogs?: FailedJobLogsFetcher
193
- ): Promise<CiStatusResult> {
194
- const summary: CiSummary = { passed: 0, failed: 0, pending: 0 };
195
- const failures: CiFailure[] = [];
196
- try {
197
- const runs = await paginate<RawRun>(
198
- octokit,
199
- budget,
200
- "GET /repos/{owner}/{repo}/actions/runs",
201
- { owner, repo, head_sha: headSha },
202
- 100,
203
- (d) => (d as { workflow_runs: RawRun[] }).workflow_runs
204
- );
205
-
206
- const failedRuns: RawRun[] = [];
207
- for (const run of runs) {
208
- const outcome = classify(run.status, run.conclusion);
209
- summary[outcome]++;
210
- if (outcome === "failed") failedRuns.push(run);
211
- }
212
-
213
- if (verbosity >= 1) {
214
- for (const run of failedRuns) {
215
- const jobs = await paginate<RawJob>(
216
- octokit,
217
- budget,
218
- "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs",
219
- { owner, repo, run_id: run.id },
220
- 100,
221
- (d) => (d as { jobs: RawJob[] }).jobs
222
- );
223
- // One get_job_logs call per FAILING RUN (not per job) — failed_only:true
224
- // already returns every failing job's log in that run at once.
225
- const logsByJobId = verbosity >= 3 && fetchFailedJobLogs ? await fetchFailedJobLogs(run.id, logLines) : undefined;
226
- for (const job of jobs) {
227
- if (classify(job.status, job.conclusion) !== "failed") continue;
228
- const failure: CiFailure = { workflow: run.name, job: job.name, runId: run.id, runUrl: run.html_url, conclusion: job.conclusion ?? "unknown" };
229
- if (verbosity >= 2) {
230
- failure.steps = (job.steps ?? [])
231
- .filter((s) => classify(s.status, s.conclusion) === "failed")
232
- .map((s) => ({ name: s.name, number: s.number, conclusion: s.conclusion ?? "unknown", durationMs: stepDurationMs(s) }));
233
- }
234
- const logTail = logsByJobId?.get(job.id);
235
- if (logTail !== undefined) failure.logTail = logTail;
236
- failures.push(failure);
237
- }
238
- }
239
- }
240
- } catch (err) {
241
- const t = truncationFrom(err);
242
- if (t) return { pull: pullNumber, summary, ...(failures.length ? { failures } : {}), ...t };
243
- throw err;
244
- }
245
-
246
- const result: CiStatusResult = { pull: pullNumber, summary };
247
- if (verbosity >= 1) result.failures = failures;
248
- return result;
249
- }
250
-
251
- function clampVerbosity(v: number | undefined): Verbosity {
252
- return Math.min(Math.max(v ?? 0, 0), 3) as Verbosity;
253
- }
254
-
255
- /**
256
- * CI status for a PR whose head sha is already known — skips the PR lookup
257
- * `reportCiStatus` would otherwise make. Used by router.ts's `pull_request_read`
258
- * augmentation, which already has the PR (and its head sha) from the forwarded
259
- * upstream response.
260
- */
261
- export async function ciStatusForHeadSha(
262
- octokit: Octokit,
263
- owner: string,
264
- repo: string,
265
- pullNumber: number,
266
- headSha: string,
267
- opts: { verbosity?: number; logLines?: number; maxApiCalls?: number; fetchFailedJobLogs?: FailedJobLogsFetcher } = {}
268
- ): Promise<CiStatusResult> {
269
- const budget = new CallBudget(Math.min(opts.maxApiCalls ?? DEFAULT_MAX_API_CALLS, HARD_MAX_API_CALLS));
270
- return computeForPull(
271
- octokit,
272
- budget,
273
- owner,
274
- repo,
275
- pullNumber,
276
- headSha,
277
- clampVerbosity(opts.verbosity),
278
- Math.min(opts.logLines ?? DEFAULT_LOG_LINES, HARD_LOG_LINES),
279
- opts.fetchFailedJobLogs
280
- );
281
- }
282
-
283
- export async function reportCiStatus(
284
- octokit: Octokit,
285
- input: ReportCiStatusInput,
286
- fetchFailedJobLogs?: FailedJobLogsFetcher
287
- ): Promise<CiStatusResult | ReportCiStatusResult> {
288
- const verbosity = clampVerbosity(input.verbosity);
289
- const logLines = Math.min(input.logLines ?? DEFAULT_LOG_LINES, HARD_LOG_LINES);
290
- const maxApiCalls = Math.min(input.maxApiCalls ?? DEFAULT_MAX_API_CALLS, HARD_MAX_API_CALLS);
291
- const budget = new CallBudget(maxApiCalls);
292
-
293
- if (input.pull !== undefined) {
294
- const pullNumber = input.pull;
295
- try {
296
- const pr = await reqOne<{ head: { sha: string } }>(octokit, budget, "GET /repos/{owner}/{repo}/pulls/{pull_number}", {
297
- owner: input.owner,
298
- repo: input.repo,
299
- pull_number: pullNumber
300
- });
301
- return await computeForPull(octokit, budget, input.owner, input.repo, pullNumber, pr.head.sha, verbosity, logLines, fetchFailedJobLogs);
302
- } catch (err) {
303
- const t = truncationFrom(err);
304
- if (t) return { pull: pullNumber, summary: { passed: 0, failed: 0, pending: 0 }, ...t };
305
- throw err;
306
- }
307
- }
308
-
309
- const maxPulls = Math.min(input.maxPulls ?? DEFAULT_MAX_PULLS, HARD_MAX_PULLS);
310
- let pulls: Array<{ number: number; head: { sha: string } }>;
311
- try {
312
- pulls = await paginate(octokit, budget, "GET /repos/{owner}/{repo}/pulls", { owner: input.owner, repo: input.repo, state: "open" }, maxPulls);
313
- } catch (err) {
314
- const t = truncationFrom(err);
315
- if (t) return { scanned: { pulls: 0 }, results: [], ...t };
316
- throw err;
317
- }
318
-
319
- const results: CiStatusResult[] = [];
320
- let scanned = 0;
321
- let aggregateTruncation: Truncation | undefined;
322
- for (const p of pulls) {
323
- const r = await computeForPull(octokit, budget, input.owner, input.repo, p.number, p.head.sha, verbosity, logLines, fetchFailedJobLogs);
324
- if (r.truncated) {
325
- // Didn't finish evaluating this PR — don't count it as scanned, but surface
326
- // it (and stop entirely: the budget/rate limit is gone for the next PR too).
327
- results.push(r);
328
- aggregateTruncation = { truncated: true, truncatedReason: r.truncatedReason as TruncatedReason, ...(r.retryAfterSeconds !== undefined ? { retryAfterSeconds: r.retryAfterSeconds } : {}) };
329
- break;
330
- }
331
- scanned++;
332
- if (r.summary.failed > 0) results.push(r);
333
- }
334
- if (!aggregateTruncation && pulls.length === maxPulls) aggregateTruncation = { truncated: true, truncatedReason: "max-pulls" };
335
-
336
- return { scanned: { pulls: scanned }, results, ...aggregateTruncation };
337
- }
338
-
339
- /** JSON Schema advertised to the agent for report_ci_status. */
340
- export const REPORT_CI_STATUS_SCHEMA = {
341
- type: "object" as const,
342
- required: ["owner", "repo"],
343
- additionalProperties: false,
344
- properties: {
345
- owner: { type: "string", description: "repository owner (org/user login) — selects the read credential" },
346
- repo: { type: "string", description: "repository name" },
347
- pull: { type: "number", description: "PR number; omit to scan every open PR in the repo (only PRs with a failure are included in the result)" },
348
- verbosity: {
349
- type: "number",
350
- enum: [0, 1, 2, 3],
351
- default: 0,
352
- description: "0: pass/fail/pending counts only. 1: + which run/job failed (workflow, job, runId, runUrl). 2: + which step in that job failed. 3: + a tail of the failing job's log."
353
- },
354
- logLines: { type: "number", description: `tier-3 only: tail lines of the failing job's log (default ${DEFAULT_LOG_LINES}, hard cap ${HARD_LOG_LINES})` },
355
- maxPulls: { type: "number", description: `safety cap on open PRs scanned when pull is omitted (default ${DEFAULT_MAX_PULLS}, hard cap ${HARD_MAX_PULLS})` },
356
- maxApiCalls: { type: "number", description: `hard ceiling on total REST calls this invocation makes (default ${DEFAULT_MAX_API_CALLS}, hard cap ${HARD_MAX_API_CALLS})` }
357
- }
358
- };