Skip to content

Commit f3196ad

Browse files
AlbinoGeekclaude
andcommitted
feat(release_readiness): what-would-ship-now tool
Compares base ref to head via REST, resolves associated PRs from commit messages via GraphQL, fetches CI status on head. Shows unreleased commits, PR cross-references, and diff stats. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3469ce0 commit f3196ad

File tree

1 file changed

+235
-0
lines changed

1 file changed

+235
-0
lines changed
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
import { gateAuth } from "./github-auth.js";
4+
import { getOctokit, graphqlQuery } from "./github-client.js";
5+
import { jsonRespond, truncateText } from "./json.js";
6+
import { FormatSchema, RepoRefSchema } from "./schemas.js";
7+
8+
interface PRNode {
9+
number: number;
10+
title: string;
11+
labels: { nodes: { name: string }[] };
12+
state: string;
13+
}
14+
15+
interface CINode {
16+
name?: string;
17+
conclusion?: string;
18+
context?: string;
19+
state?: string;
20+
}
21+
22+
interface CommitForRelease {
23+
sha7: string;
24+
message: string;
25+
author: string;
26+
date: string;
27+
pr?: { number: number; title: string; labels: string[] };
28+
}
29+
30+
function extractPRNumbers(message: string): number[] {
31+
const result: number[] = [];
32+
for (const m of message.matchAll(/\(#(\d+)\)/g)) {
33+
const raw = m[1];
34+
if (!raw) continue;
35+
const n = Number.parseInt(raw, 10);
36+
if (!Number.isNaN(n)) result.push(n);
37+
}
38+
return result;
39+
}
40+
41+
async function fetchPRMetadata(
42+
owner: string,
43+
repo: string,
44+
prNumbers: number[],
45+
): Promise<Map<number, PRNode>> {
46+
const map = new Map<number, PRNode>();
47+
if (prNumbers.length === 0) return map;
48+
49+
const batch = prNumbers.slice(0, 20);
50+
const fragments = batch.map(
51+
(n) =>
52+
`pr${n}: pullRequest(number: ${n}) { number title state labels(first:5) { nodes { name } } }`,
53+
);
54+
const query = `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){${fragments.join(" ")}}}`;
55+
56+
try {
57+
const data = await graphqlQuery<{ repository: Record<string, PRNode | null> }>(query, {
58+
owner,
59+
repo,
60+
});
61+
for (const n of batch) {
62+
const pr = data.repository[`pr${n}`];
63+
if (pr) map.set(n, pr);
64+
}
65+
} catch {
66+
// PR resolution is best-effort
67+
}
68+
return map;
69+
}
70+
71+
async function fetchHeadCI(
72+
owner: string,
73+
repo: string,
74+
headRef: string,
75+
): Promise<{ status: string; failedChecks: { name: string; conclusion: string }[] }> {
76+
const query = `query($owner:String!,$repo:String!){
77+
repository(owner:$owner,name:$repo){
78+
object(expression:"${headRef}"){
79+
...on Commit{statusCheckRollup{state contexts(first:20){nodes{...on CheckRun{name conclusion}...on StatusContext{context state}}}}}
80+
}
81+
}
82+
}`;
83+
84+
try {
85+
const data = await graphqlQuery<{
86+
repository: {
87+
object: {
88+
statusCheckRollup: { state: string; contexts: { nodes: CINode[] } } | null;
89+
} | null;
90+
};
91+
}>(query, { owner, repo });
92+
93+
const rollup = data.repository.object?.statusCheckRollup;
94+
if (!rollup) return { status: "not_configured", failedChecks: [] };
95+
96+
const failed = rollup.contexts.nodes
97+
.filter((n) => {
98+
if (n.conclusion) return !["SUCCESS", "SKIPPED"].includes(n.conclusion);
99+
if (n.state) return n.state !== "SUCCESS";
100+
return false;
101+
})
102+
.map((n) => ({
103+
name: n.name ?? n.context ?? "unknown",
104+
conclusion: n.conclusion ?? n.state ?? "unknown",
105+
}));
106+
107+
return { status: rollup.state.toLowerCase(), failedChecks: failed };
108+
} catch {
109+
return { status: "error_fetching", failedChecks: [] };
110+
}
111+
}
112+
113+
export function registerReleaseReadinessTool(server: FastMCP): void {
114+
server.addTool({
115+
name: "release_readiness",
116+
description:
117+
"What would ship if we release now? Compares a base ref (tag/branch) to head, " +
118+
"showing unreleased commits with their associated PRs, CI status on head, and " +
119+
"summary stats.",
120+
annotations: { readOnlyHint: true },
121+
parameters: RepoRefSchema.extend({
122+
base: z.string().describe("Base ref to compare from (e.g. 'v1.2.0' or 'release/1.2')."),
123+
head: z.string().optional().describe("Head ref. Defaults to the repo's default branch."),
124+
maxCommits: z.number().int().min(1).max(200).optional().default(50),
125+
format: FormatSchema,
126+
}),
127+
execute: async (args) => {
128+
const auth = gateAuth();
129+
if (!auth.ok) return jsonRespond(auth.body);
130+
131+
const octokit = getOctokit();
132+
const { owner, repo, base, maxCommits } = args;
133+
let head = args.head;
134+
135+
try {
136+
if (!head) {
137+
const repoData = await octokit.repos.get({ owner, repo });
138+
head = repoData.data.default_branch;
139+
}
140+
141+
const cmp = await octokit.repos.compareCommitsWithBasehead({
142+
owner,
143+
repo,
144+
basehead: `${base}...${head}`,
145+
});
146+
147+
const aheadBy = cmp.data.ahead_by;
148+
const rawCommits = cmp.data.commits.slice(0, maxCommits);
149+
150+
// Extract PR numbers from commit messages
151+
const allPRNumbers = new Set<number>();
152+
for (const c of rawCommits) {
153+
for (const n of extractPRNumbers(c.commit.message)) allPRNumbers.add(n);
154+
}
155+
156+
const prMap = await fetchPRMetadata(owner, repo, [...allPRNumbers]);
157+
const ciStatus = await fetchHeadCI(owner, repo, head);
158+
159+
const commits: CommitForRelease[] = rawCommits.map((c) => {
160+
const prNums = extractPRNumbers(c.commit.message);
161+
const firstPR = prNums[0] !== undefined ? prMap.get(prNums[0]) : undefined;
162+
return {
163+
sha7: c.sha.substring(0, 7),
164+
message: c.commit.message.split("\n")[0] ?? "",
165+
author: c.commit.author?.name ?? c.author?.login ?? "unknown",
166+
date: c.commit.author?.date ?? "",
167+
...(firstPR
168+
? {
169+
pr: {
170+
number: firstPR.number,
171+
title: firstPR.title,
172+
labels: firstPR.labels.nodes.map((l) => l.name),
173+
},
174+
}
175+
: {}),
176+
};
177+
});
178+
179+
const stats = {
180+
additions: (cmp.data.files ?? []).reduce((s, f) => s + f.additions, 0),
181+
deletions: (cmp.data.files ?? []).reduce((s, f) => s + f.deletions, 0),
182+
changedFiles: cmp.data.files?.length ?? 0,
183+
};
184+
185+
const result = { base, head, aheadBy, headCi: ciStatus, commits, stats };
186+
187+
if (args.format === "json") return jsonRespond(result);
188+
189+
// Markdown
190+
const lines: string[] = [
191+
`# Release Readiness: ${owner}/${repo}`,
192+
"",
193+
`**Comparing:** ${base}${head} (${aheadBy} commits ahead)`,
194+
];
195+
196+
const ciIcon =
197+
ciStatus.status === "success"
198+
? "✓ passing"
199+
: ciStatus.status === "not_configured"
200+
? "⊘ not configured"
201+
: "✗ failing";
202+
const failedNames = ciStatus.failedChecks.map((c) => c.name).join(", ");
203+
lines.push(`**CI on head:** ${ciIcon}${failedNames ? `: ${failedNames}` : ""}`);
204+
lines.push("", "## Unreleased Commits");
205+
206+
if (commits.length === 0) {
207+
lines.push("*(no commits)*");
208+
} else {
209+
lines.push("| SHA | Message | Author | PR |", "|-----|---------|--------|----|");
210+
for (const c of commits) {
211+
const msg = truncateText(c.message, 72);
212+
const pr = c.pr ? `#${c.pr.number} (${c.pr.labels.join(", ")})` : "—";
213+
lines.push(`| \`${c.sha7}\` | ${msg} | ${c.author} | ${pr} |`);
214+
}
215+
}
216+
217+
lines.push(
218+
"",
219+
"## Stats",
220+
`+${stats.additions}${stats.deletions} across ${stats.changedFiles} files`,
221+
);
222+
223+
return lines.join("\n");
224+
} catch (e) {
225+
const msg = e instanceof Error ? e.message : String(e);
226+
return jsonRespond({
227+
error: "compare_failed",
228+
base,
229+
head: head ?? "default",
230+
message: msg,
231+
});
232+
}
233+
},
234+
});
235+
}

0 commit comments

Comments
 (0)