|
| 1 | +// Review/approval integrity signals, read from structured PR-reviews data only — no diff/text/YAML parsing. |
| 2 | +// Surfaces cases a PR's own page does not always make obvious without branch protection's "dismiss stale reviews" |
| 3 | +// setting enabled: an APPROVED review that predates the current head commit (new pushes landed since the |
| 4 | +// approval), the PR author approving their own PR, and a reviewer whose CURRENT (most recent) review is still |
| 5 | +// CHANGES_REQUESTED. Reads only documented fields from the GitHub PR-reviews API (state, commit_id, user.login, |
| 6 | +// submitted_at) and compares them — no ambiguous-syntax parsing, so it cannot suffer a patch scanner's edge cases. |
| 7 | +// Pure GitHub-metadata read, no repo content. Fail-safe: no token, no head SHA, a bad repo slug, or a fetch error |
| 8 | +// all yield no finding. Bounded to MAX_PAGES pages of reviews (REVIEWS_PER_PAGE each) — a pathological PR can't |
| 9 | +// spin the analyzer, but any realistic PR's full review history is read, not just its oldest page. |
| 10 | +import type { |
| 11 | + AnalyzerDiagnostics, |
| 12 | + ApprovalIntegrityFinding, |
| 13 | + EnrichRequest, |
| 14 | +} from "../types.js"; |
| 15 | +import type { AnalysisContext } from "../analysis-context.js"; |
| 16 | +import { boundedFetchJson } from "../external-fetch.js"; |
| 17 | + |
| 18 | +const GITHUB_API = "https://api.github.com"; |
| 19 | +const SLUG_RE = /^[A-Za-z0-9._-]+$/; |
| 20 | +const REVIEWS_PER_PAGE = 100; |
| 21 | +// GitHub returns PR reviews oldest-first with no reorder option, so a single `per_page=100` fetch would silently |
| 22 | +// read only the OLDEST reviews on any PR with more — exactly backwards for "each reviewer's latest vote". Walk |
| 23 | +// pages instead, bounded so a pathological PR can't spin (mirrors this repo's own PR_DETAIL_MAX_PAGES convention). |
| 24 | +const MAX_PAGES = 10; |
| 25 | +const SHA_PREFIX_LEN = 12; |
| 26 | + |
| 27 | +interface ScanOptions { |
| 28 | + signal?: AbortSignal; |
| 29 | + analysis?: Pick<AnalysisContext, "fetchJson">; |
| 30 | + diagnostics?: AnalyzerDiagnostics; |
| 31 | +} |
| 32 | + |
| 33 | +/** The slice of a GitHub PR-review list item this analyzer reads. */ |
| 34 | +interface ReviewListItem { |
| 35 | + user?: { login?: string } | null; |
| 36 | + state?: string; |
| 37 | + commit_id?: string; |
| 38 | + submitted_at?: string | null; |
| 39 | +} |
| 40 | + |
| 41 | +/** One reviewer's current (most recent submitted) vote. */ |
| 42 | +interface LatestReview { |
| 43 | + login: string; |
| 44 | + state: string; |
| 45 | + commitId: string | undefined; |
| 46 | + submittedAt: string; |
| 47 | +} |
| 48 | + |
| 49 | +function githubHeaders(token: string): Record<string, string> { |
| 50 | + return { |
| 51 | + Authorization: `Bearer ${token}`, |
| 52 | + Accept: "application/vnd.github+json", |
| 53 | + "X-GitHub-Api-Version": "2022-11-28", |
| 54 | + }; |
| 55 | +} |
| 56 | + |
| 57 | +async function fetchReviewsPage( |
| 58 | + owner: string, |
| 59 | + repo: string, |
| 60 | + prNumber: number, |
| 61 | + page: number, |
| 62 | + headers: Record<string, string>, |
| 63 | + fetchFn: typeof fetch, |
| 64 | + signal: AbortSignal | undefined, |
| 65 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 66 | +): Promise<ReviewListItem[] | null> { |
| 67 | + const url = |
| 68 | + `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/` + |
| 69 | + `${encodeURIComponent(String(prNumber))}/reviews?per_page=${REVIEWS_PER_PAGE}&page=${page}`; |
| 70 | + const fetchOptions = { |
| 71 | + endpointCategory: "github-pr-reviews", |
| 72 | + headers, |
| 73 | + signal, |
| 74 | + fetchImpl: fetchFn, |
| 75 | + diagnostics: options.diagnostics, |
| 76 | + phase: "approval-integrity", |
| 77 | + subcall: "github-pr-reviews", |
| 78 | + maxBytes: 512 * 1024, |
| 79 | + }; |
| 80 | + const response = options.analysis |
| 81 | + ? await options.analysis.fetchJson<ReviewListItem[]>(url, fetchOptions) |
| 82 | + : await boundedFetchJson<ReviewListItem[]>(url, fetchOptions); |
| 83 | + return response.ok && Array.isArray(response.data) ? response.data : null; |
| 84 | +} |
| 85 | + |
| 86 | +/** Walks review pages (oldest-first, as GitHub returns them) up to MAX_PAGES, so `latestReviewPerReviewer` sees |
| 87 | + * every reviewer's true latest vote rather than just the oldest page. A short page (fewer than REVIEWS_PER_PAGE |
| 88 | + * items) means there is nothing further — no Link-header parsing needed. A page-1 failure yields null (same |
| 89 | + * fail-safe contract as before); a later-page failure keeps the pages already fetched rather than discarding a |
| 90 | + * successful start, mirroring this repo's own githubPaginatedList convention. */ |
| 91 | +async function fetchReviews( |
| 92 | + owner: string, |
| 93 | + repo: string, |
| 94 | + prNumber: number, |
| 95 | + headers: Record<string, string>, |
| 96 | + fetchFn: typeof fetch, |
| 97 | + signal: AbortSignal | undefined, |
| 98 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 99 | +): Promise<ReviewListItem[] | null> { |
| 100 | + const items: ReviewListItem[] = []; |
| 101 | + for (let page = 1; page <= MAX_PAGES; page += 1) { |
| 102 | + const pageItems = await fetchReviewsPage(owner, repo, prNumber, page, headers, fetchFn, signal, options); |
| 103 | + if (!pageItems) return page === 1 ? null : items; |
| 104 | + items.push(...pageItems); |
| 105 | + if (pageItems.length < REVIEWS_PER_PAGE) break; |
| 106 | + } |
| 107 | + return items; |
| 108 | +} |
| 109 | + |
| 110 | +/** Reduces a PR's review list to one entry per reviewer: the review with the latest `submitted_at`. This mirrors |
| 111 | + * GitHub's own semantics for "a reviewer's current vote" — a later review of ANY state supersedes an earlier one |
| 112 | + * from the same person, including a dismissal (the API reports a dismissed review back with `state: "DISMISSED"`, |
| 113 | + * so a dismissed CHANGES_REQUESTED naturally stops counting as outstanding without any extra handling here). |
| 114 | + * Reviews with no `submitted_at` (a still-open PENDING draft review) are excluded — not yet a submitted vote. |
| 115 | + * Login comparison is case-insensitive (GitHub logins are case-insensitive), keyed on the lowercased login. Pure. */ |
| 116 | +export function latestReviewPerReviewer(reviews: ReviewListItem[]): Map<string, LatestReview> { |
| 117 | + const latest = new Map<string, LatestReview>(); |
| 118 | + for (const review of reviews) { |
| 119 | + const login = review.user?.login; |
| 120 | + const state = review.state; |
| 121 | + const submittedAt = review.submitted_at; |
| 122 | + if (!login || !state || !submittedAt) continue; |
| 123 | + const key = login.toLowerCase(); |
| 124 | + const existing = latest.get(key); |
| 125 | + if (!existing || submittedAt > existing.submittedAt) { |
| 126 | + latest.set(key, { login, state, commitId: review.commit_id, submittedAt }); |
| 127 | + } |
| 128 | + } |
| 129 | + return latest; |
| 130 | +} |
| 131 | + |
| 132 | +/** Analyzer entrypoint: a PR's reviews → stale/self/outstanding approval-integrity findings. Fail-safe — no token, |
| 133 | + * no head SHA, a bad repo slug, or a fetch error all yield no finding rather than an error. */ |
| 134 | +export async function scanApprovalIntegrity( |
| 135 | + req: EnrichRequest, |
| 136 | + fetchFn: typeof fetch = fetch, |
| 137 | + options: ScanOptions = {}, |
| 138 | +): Promise<ApprovalIntegrityFinding[]> { |
| 139 | + const { repoFullName, githubToken, headSha, author, prNumber } = req; |
| 140 | + if (!githubToken || !headSha) return []; |
| 141 | + const parts = repoFullName.split("/"); |
| 142 | + const owner = parts[0]; |
| 143 | + const repo = parts[1]; |
| 144 | + if (parts.length !== 2 || !owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return []; |
| 145 | + |
| 146 | + const headers = githubHeaders(githubToken); |
| 147 | + const reviews = await fetchReviews(owner, repo, prNumber, headers, fetchFn, options.signal, options); |
| 148 | + if (!reviews) return []; |
| 149 | + |
| 150 | + const findings: ApprovalIntegrityFinding[] = []; |
| 151 | + const authorKey = author?.toLowerCase(); |
| 152 | + const headShaKey = headSha.toLowerCase(); |
| 153 | + for (const { login, state, commitId } of latestReviewPerReviewer(reviews).values()) { |
| 154 | + if (state === "APPROVED") { |
| 155 | + if (commitId && commitId.toLowerCase() !== headShaKey) { |
| 156 | + findings.push({ |
| 157 | + reviewer: login, |
| 158 | + kind: "stale-approval", |
| 159 | + reviewedShaPrefix: commitId.slice(0, SHA_PREFIX_LEN), |
| 160 | + }); |
| 161 | + } |
| 162 | + if (authorKey && login.toLowerCase() === authorKey) { |
| 163 | + findings.push({ reviewer: login, kind: "self-approval" }); |
| 164 | + } |
| 165 | + } else if (state === "CHANGES_REQUESTED") { |
| 166 | + findings.push({ reviewer: login, kind: "outstanding-changes-requested" }); |
| 167 | + } |
| 168 | + } |
| 169 | + return findings; |
| 170 | +} |
0 commit comments