|
| 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 } from "./json.js"; |
| 6 | +import { FormatSchema, RepoRefSchema } from "./schemas.js"; |
| 7 | + |
| 8 | +interface ReviewNode { |
| 9 | + author: { login: string }; |
| 10 | + state: string; |
| 11 | +} |
| 12 | + |
| 13 | +interface CheckContext { |
| 14 | + name?: string; |
| 15 | + context?: string; |
| 16 | + conclusion?: string | null; |
| 17 | + status?: string; |
| 18 | + state?: string; |
| 19 | +} |
| 20 | + |
| 21 | +interface PRPreflightData { |
| 22 | + repository: { |
| 23 | + pullRequest: { |
| 24 | + title: string; |
| 25 | + state: string; |
| 26 | + isDraft: boolean; |
| 27 | + mergeable: string; |
| 28 | + mergeStateStatus: string; |
| 29 | + baseRefName: string; |
| 30 | + headRefName: string; |
| 31 | + reviewDecision: string | null; |
| 32 | + labels: { nodes: { name: string }[] }; |
| 33 | + reviews: { nodes: ReviewNode[] }; |
| 34 | + reviewRequests: { |
| 35 | + nodes: { requestedReviewer: { login?: string; name?: string } }[]; |
| 36 | + }; |
| 37 | + commits: { |
| 38 | + nodes: { |
| 39 | + commit: { |
| 40 | + statusCheckRollup: { |
| 41 | + state: string; |
| 42 | + contexts: { nodes: CheckContext[] }; |
| 43 | + } | null; |
| 44 | + }; |
| 45 | + }[]; |
| 46 | + }; |
| 47 | + } | null; |
| 48 | + }; |
| 49 | +} |
| 50 | + |
| 51 | +const PR_PREFLIGHT_QUERY = ` |
| 52 | +query PRPreflight($owner: String!, $repo: String!, $number: Int!) { |
| 53 | + repository(owner: $owner, name: $repo) { |
| 54 | + pullRequest(number: $number) { |
| 55 | + title state isDraft mergeable mergeStateStatus |
| 56 | + baseRefName headRefName reviewDecision |
| 57 | + labels(first: 20) { nodes { name } } |
| 58 | + reviews(last: 20) { nodes { author { login } state } } |
| 59 | + reviewRequests(first: 10) { |
| 60 | + nodes { requestedReviewer { ... on User { login } ... on Team { name } } } |
| 61 | + } |
| 62 | + commits(last: 1) { |
| 63 | + nodes { |
| 64 | + commit { |
| 65 | + statusCheckRollup { |
| 66 | + state |
| 67 | + contexts(first: 50) { |
| 68 | + nodes { |
| 69 | + ... on CheckRun { name conclusion status } |
| 70 | + ... on StatusContext { context state } |
| 71 | + } |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + } |
| 76 | + } |
| 77 | + } |
| 78 | + } |
| 79 | +}`; |
| 80 | + |
| 81 | +export function registerPrPreflightTool(server: FastMCP): void { |
| 82 | + server.addTool({ |
| 83 | + name: "pr_preflight", |
| 84 | + description: |
| 85 | + "Pre-merge safety check for a pull request. Returns mergeable state, review " + |
| 86 | + "approvals, CI status, commits behind base, pending reviewers, and a computed " + |
| 87 | + "'safe to merge' verdict with reasons.", |
| 88 | + annotations: { readOnlyHint: true }, |
| 89 | + parameters: RepoRefSchema.extend({ |
| 90 | + number: z.number().int().positive().describe("PR number."), |
| 91 | + format: FormatSchema, |
| 92 | + }), |
| 93 | + execute: async (args) => { |
| 94 | + const auth = gateAuth(); |
| 95 | + if (!auth.ok) return jsonRespond(auth.body); |
| 96 | + |
| 97 | + const { owner, repo, number: prNumber } = args; |
| 98 | + |
| 99 | + try { |
| 100 | + const data = await graphqlQuery<PRPreflightData>(PR_PREFLIGHT_QUERY, { |
| 101 | + owner, |
| 102 | + repo, |
| 103 | + number: prNumber, |
| 104 | + }); |
| 105 | + |
| 106 | + const pr = data.repository.pullRequest; |
| 107 | + if (!pr) return jsonRespond({ error: "not_found", owner, repo, number: prNumber }); |
| 108 | + |
| 109 | + // Behind-base count via REST compare |
| 110 | + let behindBy = 0; |
| 111 | + try { |
| 112 | + const octokit = getOctokit(); |
| 113 | + const cmp = await octokit.repos.compareCommits({ |
| 114 | + owner, |
| 115 | + repo, |
| 116 | + base: pr.baseRefName, |
| 117 | + head: pr.headRefName, |
| 118 | + }); |
| 119 | + behindBy = cmp.data.behind_by ?? 0; |
| 120 | + } catch { |
| 121 | + // comparison not available |
| 122 | + } |
| 123 | + |
| 124 | + // De-duplicate reviews: latest per author |
| 125 | + const reviewMap = new Map<string, ReviewNode>(); |
| 126 | + for (const r of pr.reviews.nodes) reviewMap.set(r.author.login, r); |
| 127 | + const reviews = [...reviewMap.values()]; |
| 128 | + |
| 129 | + // Pending reviewers |
| 130 | + const pendingReviewers = pr.reviewRequests.nodes |
| 131 | + .map((n) => n.requestedReviewer.login ?? n.requestedReviewer.name ?? "unknown") |
| 132 | + .filter(Boolean); |
| 133 | + |
| 134 | + // CI |
| 135 | + const rollup = pr.commits.nodes[0]?.commit.statusCheckRollup; |
| 136 | + const ciStatus = rollup?.state ?? "UNKNOWN"; |
| 137 | + const checks = (rollup?.contexts.nodes ?? []).map((c) => ({ |
| 138 | + name: c.name ?? c.context ?? "unknown", |
| 139 | + conclusion: c.conclusion ?? c.state ?? null, |
| 140 | + status: c.status ?? c.state ?? "UNKNOWN", |
| 141 | + })); |
| 142 | + |
| 143 | + const labels = pr.labels.nodes.map((l) => l.name); |
| 144 | + |
| 145 | + // Compute verdict |
| 146 | + const reasons: string[] = []; |
| 147 | + let safe = true; |
| 148 | + |
| 149 | + if (pr.state !== "OPEN") { |
| 150 | + safe = false; |
| 151 | + reasons.push(`PR is ${pr.state}`); |
| 152 | + } |
| 153 | + if (pr.isDraft) { |
| 154 | + safe = false; |
| 155 | + reasons.push("PR is a draft"); |
| 156 | + } |
| 157 | + if (pr.mergeable === "CONFLICTING") { |
| 158 | + safe = false; |
| 159 | + reasons.push("Has merge conflicts"); |
| 160 | + } |
| 161 | + if (pr.reviewDecision === "CHANGES_REQUESTED") { |
| 162 | + safe = false; |
| 163 | + const who = reviews |
| 164 | + .filter((r) => r.state === "CHANGES_REQUESTED") |
| 165 | + .map((r) => r.author.login); |
| 166 | + reasons.push(`Changes requested by ${who.join(", ")}`); |
| 167 | + } else if (pr.reviewDecision !== "APPROVED" && pendingReviewers.length > 0) { |
| 168 | + safe = false; |
| 169 | + reasons.push("Not yet approved"); |
| 170 | + } |
| 171 | + |
| 172 | + const failingChecks = checks.filter( |
| 173 | + (c) => c.conclusion === "FAILURE" || c.conclusion === "failure", |
| 174 | + ); |
| 175 | + if (failingChecks.length > 0) { |
| 176 | + safe = false; |
| 177 | + reasons.push(`CI failing: ${failingChecks.map((c) => c.name).join(", ")}`); |
| 178 | + } |
| 179 | + const pendingChecks = checks.filter( |
| 180 | + (c) => c.conclusion === null && c.status !== "COMPLETED", |
| 181 | + ); |
| 182 | + if (pendingChecks.length > 0) { |
| 183 | + safe = false; |
| 184 | + reasons.push("CI still running"); |
| 185 | + } |
| 186 | + if (behindBy > 0) { |
| 187 | + reasons.push(`${behindBy} commits behind ${pr.baseRefName}`); |
| 188 | + } |
| 189 | + |
| 190 | + const result = { |
| 191 | + number: prNumber, |
| 192 | + title: pr.title, |
| 193 | + safe, |
| 194 | + reasons, |
| 195 | + mergeable: pr.mergeable, |
| 196 | + reviewDecision: pr.reviewDecision, |
| 197 | + reviews: reviews.map((r) => ({ author: r.author.login, state: r.state })), |
| 198 | + pendingReviewers, |
| 199 | + ci: { status: ciStatus, checks }, |
| 200 | + behindBase: behindBy, |
| 201 | + labels, |
| 202 | + conflicts: pr.mergeable === "CONFLICTING", |
| 203 | + }; |
| 204 | + |
| 205 | + if (args.format === "json") return jsonRespond(result); |
| 206 | + |
| 207 | + // Markdown |
| 208 | + const icon = safe ? "✓" : "✗"; |
| 209 | + const verdict = safe ? "Safe to merge" : "NOT safe to merge"; |
| 210 | + const blockers = reasons.filter((r) => !r.includes("commits behind")); |
| 211 | + const warnings = reasons.filter((r) => r.includes("commits behind")); |
| 212 | + |
| 213 | + let md = `# PR Preflight: ${owner}/${repo}#${prNumber}\n\n`; |
| 214 | + md += `## ${icon} ${verdict}\n\n`; |
| 215 | + |
| 216 | + if (blockers.length > 0) { |
| 217 | + md += "**Blockers:**\n"; |
| 218 | + for (const b of blockers) md += `- ✗ ${b}\n`; |
| 219 | + md += "\n"; |
| 220 | + } |
| 221 | + if (warnings.length > 0) { |
| 222 | + md += "**Warnings:**\n"; |
| 223 | + for (const w of warnings) md += `- ⚠ ${w}\n`; |
| 224 | + md += "\n"; |
| 225 | + } |
| 226 | + |
| 227 | + md += "| Check | Status |\n|-------|--------|\n"; |
| 228 | + |
| 229 | + // Reviews |
| 230 | + if (pr.reviewDecision === "APPROVED") { |
| 231 | + const who = reviews |
| 232 | + .filter((r) => r.state === "APPROVED") |
| 233 | + .map((r) => `${r.author.login} ✓`); |
| 234 | + md += `| Reviews | ✓ APPROVED (${who.join(", ")}) |\n`; |
| 235 | + } else if (pr.reviewDecision === "CHANGES_REQUESTED") { |
| 236 | + const who = reviews |
| 237 | + .filter((r) => r.state === "CHANGES_REQUESTED") |
| 238 | + .map((r) => r.author.login); |
| 239 | + md += `| Reviews | ✗ Changes requested by ${who.join(", ")} |\n`; |
| 240 | + } else { |
| 241 | + md += `| Reviews | ⚠ Pending (${pendingReviewers.join(", ") || "none"}) |\n`; |
| 242 | + } |
| 243 | + |
| 244 | + // CI |
| 245 | + if (failingChecks.length > 0) { |
| 246 | + md += `| CI | ✗ ${failingChecks.length}/${checks.length} failing |\n`; |
| 247 | + } else if (pendingChecks.length > 0) { |
| 248 | + md += `| CI | ⚠ Running (${pendingChecks.length}/${checks.length}) |\n`; |
| 249 | + } else { |
| 250 | + md += "| CI | ✓ Passing |\n"; |
| 251 | + } |
| 252 | + |
| 253 | + md += `| Conflicts | ${pr.mergeable === "CONFLICTING" ? "✗ Has conflicts" : "✓ None"} |\n`; |
| 254 | + if (pendingReviewers.length > 0) { |
| 255 | + md += `| Pending reviewers | ${pendingReviewers.join(", ")} |\n`; |
| 256 | + } |
| 257 | + if (labels.length > 0) { |
| 258 | + md += `| Labels | ${labels.join(", ")} |\n`; |
| 259 | + } |
| 260 | + |
| 261 | + return md; |
| 262 | + } catch (e) { |
| 263 | + const msg = e instanceof Error ? e.message : String(e); |
| 264 | + return jsonRespond({ error: "query_failed", message: msg, owner, repo, number: prNumber }); |
| 265 | + } |
| 266 | + }, |
| 267 | + }); |
| 268 | +} |
0 commit comments