|
| 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