|
| 1 | +// Stale-branch signal, read from structured GitHub repo/compare API fields only — no diff/text/log parsing. |
| 2 | +// Surfaces a PR whose branch is significantly BEHIND the repo's current default branch — a staleness risk the |
| 3 | +// PR page itself does not summarize as a number (GitHub's own UI only shows "This branch is out-of-date", not |
| 4 | +// how far). A branch far behind is more likely to hide a subtle semantic conflict a clean `mergeable` check |
| 5 | +// would miss. Reads only documented fields from the GitHub repo API (`default_branch`) and the compare API |
| 6 | +// (`status`, `behind_by`) — no ambiguous-syntax parsing, so it cannot suffer a patch scanner's edge cases. Pure |
| 7 | +// GitHub-metadata read, no repo content. Fail-safe: no token, no head SHA, a bad repo slug, or either fetch |
| 8 | +// failing all yield no finding rather than an error. |
| 9 | +import type { |
| 10 | + AnalyzerDiagnostics, |
| 11 | + EnrichRequest, |
| 12 | + StaleBranchFinding, |
| 13 | +} from "../types.js"; |
| 14 | +import type { AnalysisContext } from "../analysis-context.js"; |
| 15 | +import { boundedFetchJson } from "../external-fetch.js"; |
| 16 | + |
| 17 | +const GITHUB_API = "https://api.github.com"; |
| 18 | +const SLUG_RE = /^[A-Za-z0-9._-]+$/; |
| 19 | +// Below this many commits behind, drifting from the default branch is normal PR life, not a staleness risk. |
| 20 | +const BEHIND_THRESHOLD = 100; |
| 21 | + |
| 22 | +interface ScanOptions { |
| 23 | + signal?: AbortSignal; |
| 24 | + analysis?: Pick<AnalysisContext, "fetchJson">; |
| 25 | + diagnostics?: AnalyzerDiagnostics; |
| 26 | +} |
| 27 | + |
| 28 | +interface RepoInfo { |
| 29 | + default_branch?: string; |
| 30 | +} |
| 31 | + |
| 32 | +interface CompareResult { |
| 33 | + status?: string; |
| 34 | + behind_by?: number; |
| 35 | +} |
| 36 | + |
| 37 | +function githubHeaders(token: string): Record<string, string> { |
| 38 | + return { |
| 39 | + Authorization: `Bearer ${token}`, |
| 40 | + Accept: "application/vnd.github+json", |
| 41 | + "X-GitHub-Api-Version": "2022-11-28", |
| 42 | + }; |
| 43 | +} |
| 44 | + |
| 45 | +async function fetchDefaultBranch( |
| 46 | + owner: string, |
| 47 | + repo: string, |
| 48 | + headers: Record<string, string>, |
| 49 | + fetchFn: typeof fetch, |
| 50 | + signal: AbortSignal | undefined, |
| 51 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 52 | +): Promise<string | null> { |
| 53 | + const url = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; |
| 54 | + const fetchOptions = { |
| 55 | + endpointCategory: "github-repo-info", |
| 56 | + headers, |
| 57 | + signal, |
| 58 | + fetchImpl: fetchFn, |
| 59 | + diagnostics: options.diagnostics, |
| 60 | + phase: "stale-branch", |
| 61 | + subcall: "github-repo-info", |
| 62 | + maxBytes: 128 * 1024, |
| 63 | + }; |
| 64 | + const response = options.analysis |
| 65 | + ? await options.analysis.fetchJson<RepoInfo>(url, fetchOptions) |
| 66 | + : await boundedFetchJson<RepoInfo>(url, fetchOptions); |
| 67 | + return response.ok && typeof response.data.default_branch === "string" && response.data.default_branch |
| 68 | + ? response.data.default_branch |
| 69 | + : null; |
| 70 | +} |
| 71 | + |
| 72 | +async function fetchCompare( |
| 73 | + owner: string, |
| 74 | + repo: string, |
| 75 | + base: string, |
| 76 | + head: string, |
| 77 | + headers: Record<string, string>, |
| 78 | + fetchFn: typeof fetch, |
| 79 | + signal: AbortSignal | undefined, |
| 80 | + options: Pick<ScanOptions, "analysis" | "diagnostics">, |
| 81 | +): Promise<CompareResult | null> { |
| 82 | + const url = |
| 83 | + `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/compare/` + |
| 84 | + `${encodeURIComponent(base)}...${encodeURIComponent(head)}`; |
| 85 | + const fetchOptions = { |
| 86 | + endpointCategory: "github-compare", |
| 87 | + headers, |
| 88 | + signal, |
| 89 | + fetchImpl: fetchFn, |
| 90 | + diagnostics: options.diagnostics, |
| 91 | + phase: "stale-branch", |
| 92 | + subcall: "github-compare", |
| 93 | + maxBytes: 256 * 1024, |
| 94 | + }; |
| 95 | + const response = options.analysis |
| 96 | + ? await options.analysis.fetchJson<CompareResult>(url, fetchOptions) |
| 97 | + : await boundedFetchJson<CompareResult>(url, fetchOptions); |
| 98 | + return response.ok ? response.data : null; |
| 99 | +} |
| 100 | + |
| 101 | +/** Pure: a repo's default branch + a compare-API result → a stale-branch finding, when behind_by crosses the |
| 102 | + * fixed threshold. `behind_by` must be a finite non-negative number — a missing/malformed field fails closed |
| 103 | + * (no finding) rather than guessing. Pure. */ |
| 104 | +export function evaluateStaleBranch(defaultBranch: string, compare: CompareResult): StaleBranchFinding[] { |
| 105 | + const behindBy = compare.behind_by; |
| 106 | + if (typeof behindBy !== "number" || !Number.isFinite(behindBy) || behindBy < 0) return []; |
| 107 | + if (behindBy < BEHIND_THRESHOLD) return []; |
| 108 | + return [{ defaultBranch, behindBy }]; |
| 109 | +} |
| 110 | + |
| 111 | +/** Analyzer entrypoint: how far this PR's head is behind the repo's CURRENT default branch → a stale-branch |
| 112 | + * finding, when significant. Fail-safe — no token, no head SHA, a bad repo slug, or either fetch failing all |
| 113 | + * yield no finding rather than an error. */ |
| 114 | +export async function scanStaleBranch( |
| 115 | + req: EnrichRequest, |
| 116 | + fetchFn: typeof fetch = fetch, |
| 117 | + options: ScanOptions = {}, |
| 118 | +): Promise<StaleBranchFinding[]> { |
| 119 | + const { repoFullName, githubToken, headSha } = req; |
| 120 | + if (!githubToken || !headSha) return []; |
| 121 | + const parts = repoFullName.split("/"); |
| 122 | + const owner = parts[0]; |
| 123 | + const repo = parts[1]; |
| 124 | + if (parts.length !== 2 || !owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return []; |
| 125 | + |
| 126 | + const headers = githubHeaders(githubToken); |
| 127 | + const defaultBranch = await fetchDefaultBranch(owner, repo, headers, fetchFn, options.signal, options); |
| 128 | + if (!defaultBranch) return []; |
| 129 | + const compare = await fetchCompare(owner, repo, defaultBranch, headSha, headers, fetchFn, options.signal, options); |
| 130 | + if (!compare) return []; |
| 131 | + |
| 132 | + return evaluateStaleBranch(defaultBranch, compare); |
| 133 | +} |
0 commit comments