Skip to content

Commit 66194ea

Browse files
authored
feat(enrichment): flag PRs significantly behind the default branch (#3145)
1 parent 2e8f932 commit 66194ea

11 files changed

Lines changed: 359 additions & 4 deletions

File tree

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,19 @@ GITTENSORY_REVIEW_ENRICHMENT=false
6666
# dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol,redos
6767
# provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig,nativeBuild
6868
# history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity,ciCheckSignals
69-
# undocumentedExport
69+
# undocumentedExport,staleBranch
7070
#
7171
# Profile defaults:
7272
# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7373
# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild
7474
# balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency
7575
# actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature
7676
# iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink
77-
# approvalIntegrity,ciCheckSignals,undocumentedExport
77+
# approvalIntegrity,ciCheckSignals,undocumentedExport,staleBranch
7878
# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7979
# redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
8080
# nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity
81-
# ciCheckSignals,undocumentedExport
81+
# ciCheckSignals,undocumentedExport,staleBranch
8282
# END GENERATED REES ANALYZERS
8383

8484
# Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep

apps/gittensory-ui/src/lib/rees-analyzers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,28 @@ export const REES_ANALYZERS = [
657657
"Conservative: re-export lists (`export { x }`) and `export *` are ignored; a preceding `//` line (except tool directives like `eslint-disable`) or a real JSDoc `/**` block counts as documented (a plain `/* … */` block does not).",
658658
},
659659
},
660+
{
661+
name: "staleBranch",
662+
title: "Stale branch signal",
663+
category: "history",
664+
cost: "github-light",
665+
defaultEnabled: true,
666+
profiles: ["balanced", "deep"],
667+
requires: ["github-token", "head-sha"],
668+
limits: {
669+
behindThreshold: 100,
670+
},
671+
docs: {
672+
summary:
673+
"Flags a PR whose head is significantly behind the repo's current default branch — a staleness risk a clean `mergeable` check alone would not surface.",
674+
looksAt:
675+
"The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
676+
reports: "The default branch name and the commit count behind it — never commit content.",
677+
network: "Calls the GitHub repo API once and the compare API once.",
678+
notes:
679+
"Structured-fields-only: reads default_branch and behind_by, never diff or commit text. Fail-safe on missing token/head SHA/either fetch failing.",
680+
},
681+
},
660682
] as const satisfies readonly ReesAnalyzerDoc[];
661683

662684
export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name);

review-enrichment/analyzer-metadata.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,31 @@
741741
"network": "One GitHub contents fetch per changed entrypoint (at headSha). Requires GitHub token forwarding for private repos.",
742742
"notes": "Conservative: re-export lists (`export { x }`) and `export *` are ignored; a preceding `//` line (except tool directives like `eslint-disable`) or a real JSDoc `/**` block counts as documented (a plain `/* … */` block does not)."
743743
}
744+
},
745+
{
746+
"name": "staleBranch",
747+
"title": "Stale branch signal",
748+
"category": "history",
749+
"cost": "github-light",
750+
"defaultEnabled": true,
751+
"profiles": [
752+
"balanced",
753+
"deep"
754+
],
755+
"requires": [
756+
"github-token",
757+
"head-sha"
758+
],
759+
"limits": {
760+
"behindThreshold": 100
761+
},
762+
"docs": {
763+
"summary": "Flags a PR whose head is significantly behind the repo's current default branch — a staleness risk a clean `mergeable` check alone would not surface.",
764+
"looksAt": "The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
765+
"reports": "The default branch name and the commit count behind it — never commit content.",
766+
"network": "Calls the GitHub repo API once and the compare API once.",
767+
"notes": "Structured-fields-only: reads default_branch and behind_by, never diff or commit text. Fail-safe on missing token/head SHA/either fetch failing."
768+
}
744769
}
745770
]
746771
}

review-enrichment/src/analyzers/registry.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { scanProvenance } from "./provenance.js";
2121
import { scanRedos } from "./redos.js";
2222
import { secretAnalyzer } from "./secret/descriptor.js";
2323
import { scanSecretLog } from "./secret-log.js";
24+
import { scanStaleBranch } from "./stale-branch.js";
2425
import { scanTyposquat } from "./typosquat.js";
2526
import { scanUndocumentedExport } from "./undocumented-export.js";
2627
import type {
@@ -585,6 +586,36 @@ export const ANALYZER_DESCRIPTORS = [
585586
},
586587
run: (req, { signal }) => scanUndocumentedExport(req, fetch, { signal }),
587588
}),
589+
descriptor({
590+
name: "staleBranch",
591+
title: "Stale branch signal",
592+
category: "history",
593+
cost: "github-light",
594+
defaultEnabled: true,
595+
requires: ["github-token", "head-sha"],
596+
limits: { behindThreshold: 100 },
597+
docs: {
598+
summary:
599+
"Flags a PR whose head is significantly behind the repo's current default branch — a staleness risk a clean `mergeable` check alone would not surface.",
600+
looksAt: "The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
601+
reports: "The default branch name and the commit count behind it — never commit content.",
602+
network: "Calls the GitHub repo API once and the compare API once.",
603+
notes:
604+
"Structured-fields-only: reads default_branch and behind_by, never diff or commit text. Fail-safe on missing token/head SHA/either fetch failing.",
605+
},
606+
render: (findings, helpers) => {
607+
if (!findings.length) return [];
608+
const lines = ["### Stale branch signal"];
609+
for (const item of findings) {
610+
lines.push(
611+
`- This PR is ${item.behindBy} commits behind ${helpers.safeCodeSpan(item.defaultBranch)}`,
612+
);
613+
}
614+
return lines;
615+
},
616+
run: (req, { signal, analysis, diagnostics }) =>
617+
scanStaleBranch(req, fetch, { signal, analysis, diagnostics }),
618+
}),
588619
] as const satisfies readonly AnyAnalyzerDescriptor[];
589620

590621
export const ANALYZER_NAMES = ANALYZER_DESCRIPTORS.map(
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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+
}

review-enrichment/src/render.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ export function renderBrief(
416416
lines.push(...renderDescriptorSection("approvalIntegrity", findings.approvalIntegrity));
417417
lines.push(...renderDescriptorSection("ciCheckSignals", findings.ciCheckSignals));
418418
lines.push(...renderDescriptorSection("undocumentedExport", findings.undocumentedExport));
419+
lines.push(...renderDescriptorSection("staleBranch", findings.staleBranch));
419420

420421
if (!lines.length) return { promptSection: "", systemSuffix: "" };
421422

review-enrichment/src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,14 @@ export type CiCheckSignalFinding =
345345
| { checkName: string; kind: "retried-after-failure"; failedAttempts: number }
346346
| { checkName: string; kind: "long-running-check"; durationMinutes: number };
347347

348+
/** A PR whose head is significantly behind the repo's CURRENT default branch, read from structured GitHub repo
349+
* (`default_branch`) and compare (`behind_by`) API fields only — never diff/file content. A branch far behind
350+
* is more likely to hide a subtle semantic conflict a clean `mergeable` check alone would miss. */
351+
export interface StaleBranchFinding {
352+
defaultBranch: string;
353+
behindBy: number;
354+
}
355+
348356
/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */
349357
export interface BriefFindings {
350358
dependency?: DependencyFinding[];
@@ -372,6 +380,7 @@ export interface BriefFindings {
372380
approvalIntegrity?: ApprovalIntegrityFinding[];
373381
ciCheckSignals?: CiCheckSignalFinding[];
374382
undocumentedExport?: UndocumentedExportFinding[];
383+
staleBranch?: StaleBranchFinding[];
375384
}
376385

377386
/** A JSDoc/TSDoc block whose `@param` tags name parameters the adjacent function no longer declares — a

review-enrichment/test/analyzer-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const EXPECTED_ANALYZERS = [
3535
"approvalIntegrity",
3636
"ciCheckSignals",
3737
"undocumentedExport",
38+
"staleBranch",
3839
];
3940

4041
test("analyzer descriptors cover the runtime registry in stable order", () => {

0 commit comments

Comments
 (0)