|
| 1 | +// Blame-to-PR regression linker (#2034, part of #1499). For files this PR MODIFIES or DELETES, surfaces the prior |
| 2 | +// PR that most recently touched that FILE, so the reviewer sees at a glance what recent history the change sits on |
| 3 | +// top of. It is deliberately NOT per-line blame (no checkout, no blame API): for each touched file it reads the |
| 4 | +// path's most recent commit on the base branch and maps that commit to its PR via the commit→PR association API — |
| 5 | +// so the result is file-level "last touched by", never a claim that the surfaced PR introduced a specific line. |
| 6 | +// Bounded (maxFilesProbed + maxLookups) and fail-safe — any missing token, bad slug, or fetch error yields no |
| 7 | +// finding rather than an error. Surfaces only a PR number and a short SHA prefix, never contents. |
| 8 | +import type { |
| 9 | + AnalyzerDiagnostics, |
| 10 | + EnrichRequest, |
| 11 | + BlameLinkFinding, |
| 12 | +} from "../types.js"; |
| 13 | +import type { AnalysisContext } from "../analysis-context.js"; |
| 14 | +import { boundedFetchJson } from "../external-fetch.js"; |
| 15 | + |
| 16 | +const GITHUB_API = "https://api.github.com"; |
| 17 | +const SLUG_RE = /^[A-Za-z0-9._-]+$/; |
| 18 | +const MAX_FILES_PROBED = 6; // bound the files we probe, matching the other history-class analyzers |
| 19 | +const MAX_LOOKUPS = 12; // hard cap on total GitHub round-trips (each file costs up to 2: commits + pulls) |
| 20 | +const SHA_PREFIX_LEN = 12; |
| 21 | +// Files whose commit history is not a useful "who introduced this" signal — lockfiles, generated output, binaries. |
| 22 | +const SKIP_RE = |
| 23 | + /(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|go\.sum)$|\.(?:lock|min\.js|map|snap|png|jpe?g|gif|svg|ico|pdf|zip|gz|woff2?)$|(?:^|\/)(?:dist|build|vendor)\//i; |
| 24 | + |
| 25 | +interface ScanOptions { |
| 26 | + signal?: AbortSignal; |
| 27 | + analysis?: Pick<AnalysisContext, "fetchJson">; |
| 28 | + diagnostics?: AnalyzerDiagnostics; |
| 29 | +} |
| 30 | + |
| 31 | +/** The slice of a GitHub commit-list item this analyzer reads. */ |
| 32 | +interface CommitListItem { |
| 33 | + sha?: string; |
| 34 | +} |
| 35 | +/** The slice of a commit→PR association item this analyzer reads. */ |
| 36 | +interface AssociatedPr { |
| 37 | + number?: number; |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * The old-file line number of the FIRST line this patch modifies or deletes, or null when the patch only ADDS |
| 42 | + * lines (nothing pre-existing is being altered, so there is no prior author to attribute). Walks unified-diff |
| 43 | + * hunks: a `@@ -old,+new @@` header resets the old-line cursor; a deletion line reports the cursor; only a real |
| 44 | + * space-prefixed CONTEXT line advances it. Additions, the `\ No newline` marker, and any malformed/extended patch |
| 45 | + * text are NOT counted as old-file lines, so a garbled patch fails closed (returns null) rather than reporting a |
| 46 | + * drifted line. Pure. */ |
| 47 | +export function firstTouchedOldLine(patch: string): number | null { |
| 48 | + let oldLine = 0; |
| 49 | + let inHunk = false; |
| 50 | + for (const raw of patch.split("\n")) { |
| 51 | + const header = raw.match(/^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/); |
| 52 | + if (header) { |
| 53 | + oldLine = Number(header[1]); |
| 54 | + inHunk = true; |
| 55 | + continue; |
| 56 | + } |
| 57 | + if (!inHunk) continue; // file headers (`---`/`+++`) only appear before a hunk; the flag skips them |
| 58 | + // Inside a hunk EVERY `-`-prefixed line is an old-file deletion — including content that itself starts with |
| 59 | + // `--`/`---` (git renders a deleted `--x` line as `---x`). The marker is the first char; the rest is content. |
| 60 | + if (raw.startsWith("-")) return oldLine; // first modified/deleted old-file line |
| 61 | + if (raw.startsWith(" ")) oldLine += 1; // a real context line — the ONLY thing that advances the old cursor |
| 62 | + // Everything else (additions, `\`/`+` markers, malformed text) is not an old-file line: do not advance. |
| 63 | + } |
| 64 | + return null; |
| 65 | +} |
| 66 | + |
| 67 | +function githubHeaders(token: string): Record<string, string> { |
| 68 | + return { |
| 69 | + Authorization: `Bearer ${token}`, |
| 70 | + Accept: "application/vnd.github+json", |
| 71 | + "X-GitHub-Api-Version": "2022-11-28", |
| 72 | + }; |
| 73 | +} |
| 74 | + |
| 75 | +async function fetchGithubJson<T>( |
| 76 | + url: string, |
| 77 | + headers: Record<string, string>, |
| 78 | + fetchFn: typeof fetch, |
| 79 | + signal: AbortSignal | undefined, |
| 80 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 81 | + subcall: string, |
| 82 | + endpointCategory: string, |
| 83 | +): Promise<T | null> { |
| 84 | + const fetchOptions = { |
| 85 | + endpointCategory, |
| 86 | + headers, |
| 87 | + signal, |
| 88 | + fetchImpl: fetchFn, |
| 89 | + diagnostics: options.diagnostics, |
| 90 | + phase: "blame-link", |
| 91 | + subcall, |
| 92 | + maxBytes: 256 * 1024, |
| 93 | + maxCallsPerCategory: MAX_LOOKUPS, |
| 94 | + }; |
| 95 | + const response = options.analysis |
| 96 | + ? await options.analysis.fetchJson<T>(url, fetchOptions) |
| 97 | + : await boundedFetchJson<T>(url, fetchOptions); |
| 98 | + return response.ok ? response.data : null; |
| 99 | +} |
| 100 | + |
| 101 | +/** The SHA of the most recent commit touching `path` on the base branch, or null. Anchoring to `baseSha` keeps |
| 102 | + * the PR's own commits out of the answer so we attribute PRIOR authorship, not this change. */ |
| 103 | +export async function fetchLatestCommitSha( |
| 104 | + owner: string, |
| 105 | + repo: string, |
| 106 | + path: string, |
| 107 | + baseSha: string | undefined, |
| 108 | + headers: Record<string, string>, |
| 109 | + fetchFn: typeof fetch, |
| 110 | + signal: AbortSignal | undefined, |
| 111 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 112 | +): Promise<string | null> { |
| 113 | + const shaQuery = baseSha ? `&sha=${encodeURIComponent(baseSha)}` : ""; |
| 114 | + const url = |
| 115 | + `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits` + |
| 116 | + `?path=${encodeURIComponent(path)}&per_page=1${shaQuery}`; |
| 117 | + const commits = await fetchGithubJson<CommitListItem[]>( |
| 118 | + url, headers, fetchFn, signal, options, "github-commits", "github-commits", |
| 119 | + ); |
| 120 | + const sha = Array.isArray(commits) ? commits[0]?.sha : undefined; |
| 121 | + return typeof sha === "string" && sha ? sha : null; |
| 122 | +} |
| 123 | + |
| 124 | +/** The number of the PR that a commit belongs to, via the commit→PR association API, or null when unassociated. */ |
| 125 | +export async function fetchPrForCommit( |
| 126 | + owner: string, |
| 127 | + repo: string, |
| 128 | + sha: string, |
| 129 | + headers: Record<string, string>, |
| 130 | + fetchFn: typeof fetch, |
| 131 | + signal: AbortSignal | undefined, |
| 132 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 133 | +): Promise<number | null> { |
| 134 | + const url = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(sha)}/pulls`; |
| 135 | + const pulls = await fetchGithubJson<AssociatedPr[]>( |
| 136 | + url, headers, fetchFn, signal, options, "github-commit-pulls", "github-commit-pulls", |
| 137 | + ); |
| 138 | + const number = Array.isArray(pulls) ? pulls[0]?.number : undefined; |
| 139 | + return typeof number === "number" ? number : null; |
| 140 | +} |
| 141 | + |
| 142 | +/** Analyzer entrypoint: changed files that alter existing lines → the prior PR/commit that introduced them. |
| 143 | + * Fail-safe — no token, bad slug, or fetch error yields no finding. Stops and returns partial results at the |
| 144 | + * lookup cap. */ |
| 145 | +export async function scanBlameLink( |
| 146 | + req: EnrichRequest, |
| 147 | + fetchFn: typeof fetch = fetch, |
| 148 | + options: ScanOptions = {}, |
| 149 | +): Promise<BlameLinkFinding[]> { |
| 150 | + const { repoFullName, githubToken, baseSha, files = [] } = req; |
| 151 | + if (!githubToken) return []; |
| 152 | + const parts = repoFullName.split("/"); |
| 153 | + const owner = parts[0]; |
| 154 | + const repo = parts[1]; |
| 155 | + if (parts.length !== 2 || !owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return []; |
| 156 | + |
| 157 | + const headers = githubHeaders(githubToken); |
| 158 | + // Only files that alter pre-existing lines can be blamed: skip added files, generated/binary paths, and pure |
| 159 | + // additions (a patch with no deletion line has no prior author to attribute). |
| 160 | + const candidates: Array<{ lookupPath: string; displayPath: string; line: number }> = []; |
| 161 | + for (const file of files) { |
| 162 | + if (file.status === "added" || SKIP_RE.test(file.path)) continue; |
| 163 | + let line = file.patch ? firstTouchedOldLine(file.patch) : null; |
| 164 | + // A removed OR renamed file resolves against the base tree even without a usable patch (binary/truncated, or a |
| 165 | + // pure rename with no content change). Anchor to line 1 as the representative point. |
| 166 | + if (line === null && (file.status === "removed" || file.status === "renamed")) line = 1; |
| 167 | + if (line === null) continue; // a modified file with only additions / no usable patch → nothing to blame |
| 168 | + // The base tree holds a renamed file under its OLD path, so resolve history against `previousPath` while still |
| 169 | + // showing the reviewer the new (display) path. |
| 170 | + const lookupPath = file.status === "renamed" && file.previousPath ? file.previousPath : file.path; |
| 171 | + candidates.push({ lookupPath, displayPath: file.path, line }); |
| 172 | + if (candidates.length >= MAX_FILES_PROBED) break; |
| 173 | + } |
| 174 | + |
| 175 | + const findings: BlameLinkFinding[] = []; |
| 176 | + let lookups = 0; |
| 177 | + for (const { lookupPath, displayPath, line } of candidates) { |
| 178 | + if (options.signal?.aborted) break; |
| 179 | + if (lookups >= MAX_LOOKUPS) break; // cap reached → emit partial |
| 180 | + lookups += 1; |
| 181 | + const sha = await fetchLatestCommitSha(owner, repo, lookupPath, baseSha, headers, fetchFn, options.signal, options); |
| 182 | + if (!sha) continue; // no prior commit on the path → no finding |
| 183 | + let lastTouchedByPr: number | null = null; |
| 184 | + if (lookups < MAX_LOOKUPS && !options.signal?.aborted) { |
| 185 | + lookups += 1; |
| 186 | + lastTouchedByPr = await fetchPrForCommit(owner, repo, sha, headers, fetchFn, options.signal, options); |
| 187 | + } |
| 188 | + findings.push({ |
| 189 | + file: displayPath, |
| 190 | + line, |
| 191 | + lastTouchedByShaPrefix: sha.slice(0, SHA_PREFIX_LEN), |
| 192 | + ...(lastTouchedByPr !== null ? { lastTouchedByPr } : {}), |
| 193 | + }); |
| 194 | + } |
| 195 | + return findings; |
| 196 | +} |
0 commit comments