Skip to content

Commit 1206f10

Browse files
committed
feat(enrichment): flag PRs significantly behind the default branch
1 parent 86dde13 commit 1206f10

9 files changed

Lines changed: 356 additions & 2 deletions

File tree

.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +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+
# staleBranch
6970
#
7071
# Profile defaults:
7172
# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7273
# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild
7374
# balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency
7475
# actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature
7576
# iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink
76-
# approvalIntegrity,ciCheckSignals
77+
# approvalIntegrity,ciCheckSignals,staleBranch
7778
# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7879
# redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
7980
# nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity
80-
# ciCheckSignals
81+
# ciCheckSignals,staleBranch
8182
# END GENERATED REES ANALYZERS
8283

8384
# 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
@@ -632,6 +632,28 @@ export const REES_ANALYZERS = [
632632
"Structured-fields-only: reads name/status/conclusion/started_at/completed_at, never check output or logs. Fail-safe on missing token/head SHA/fetch error.",
633633
},
634634
},
635+
{
636+
name: "staleBranch",
637+
title: "Stale branch signal",
638+
category: "history",
639+
cost: "github-light",
640+
defaultEnabled: true,
641+
profiles: ["balanced", "deep"],
642+
requires: ["github-token", "head-sha"],
643+
limits: {
644+
behindThreshold: 100,
645+
},
646+
docs: {
647+
summary:
648+
"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.",
649+
looksAt:
650+
"The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
651+
reports: "The default branch name and the commit count behind it — never commit content.",
652+
network: "Calls the GitHub repo API once and the compare API once.",
653+
notes:
654+
"Structured-fields-only: reads default_branch and behind_by, never diff or commit text. Fail-safe on missing token/head SHA/either fetch failing.",
655+
},
656+
},
635657
] as const satisfies readonly ReesAnalyzerDoc[];
636658

637659
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
@@ -714,6 +714,31 @@
714714
"network": "Calls the GitHub check-runs API once, bounded to one page.",
715715
"notes": "Structured-fields-only: reads name/status/conclusion/started_at/completed_at, never check output or logs. Fail-safe on missing token/head SHA/fetch error."
716716
}
717+
},
718+
{
719+
"name": "staleBranch",
720+
"title": "Stale branch signal",
721+
"category": "history",
722+
"cost": "github-light",
723+
"defaultEnabled": true,
724+
"profiles": [
725+
"balanced",
726+
"deep"
727+
],
728+
"requires": [
729+
"github-token",
730+
"head-sha"
731+
],
732+
"limits": {
733+
"behindThreshold": 100
734+
},
735+
"docs": {
736+
"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.",
737+
"looksAt": "The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
738+
"reports": "The default branch name and the commit count behind it — never commit content.",
739+
"network": "Calls the GitHub repo API once and the compare API once.",
740+
"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."
741+
}
717742
}
718743
]
719744
}

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 type {
2627
AnalyzerDescriptor,
@@ -554,6 +555,36 @@ export const ANALYZER_DESCRIPTORS = [
554555
run: (req, { signal, analysis, diagnostics }) =>
555556
scanCiCheckSignals(req, fetch, { signal, analysis, diagnostics }),
556557
}),
558+
descriptor({
559+
name: "staleBranch",
560+
title: "Stale branch signal",
561+
category: "history",
562+
cost: "github-light",
563+
defaultEnabled: true,
564+
requires: ["github-token", "head-sha"],
565+
limits: { behindThreshold: 100 },
566+
docs: {
567+
summary:
568+
"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.",
569+
looksAt: "The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
570+
reports: "The default branch name and the commit count behind it — never commit content.",
571+
network: "Calls the GitHub repo API once and the compare API once.",
572+
notes:
573+
"Structured-fields-only: reads default_branch and behind_by, never diff or commit text. Fail-safe on missing token/head SHA/either fetch failing.",
574+
},
575+
render: (findings, helpers) => {
576+
if (!findings.length) return [];
577+
const lines = ["### Stale branch signal"];
578+
for (const item of findings) {
579+
lines.push(
580+
`- This PR is ${item.behindBy} commits behind ${helpers.safeCodeSpan(item.defaultBranch)}`,
581+
);
582+
}
583+
return lines;
584+
},
585+
run: (req, { signal, analysis, diagnostics }) =>
586+
scanStaleBranch(req, fetch, { signal, analysis, diagnostics }),
587+
}),
557588
] as const satisfies readonly AnyAnalyzerDescriptor[];
558589

559590
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
@@ -382,6 +382,7 @@ export function renderBrief(
382382
lines.push(...renderDescriptorSection("blameLink", findings.blameLink));
383383
lines.push(...renderDescriptorSection("approvalIntegrity", findings.approvalIntegrity));
384384
lines.push(...renderDescriptorSection("ciCheckSignals", findings.ciCheckSignals));
385+
lines.push(...renderDescriptorSection("staleBranch", findings.staleBranch));
385386

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

review-enrichment/src/types.ts

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

328+
/** A PR whose head is significantly behind the repo's CURRENT default branch, read from structured GitHub repo
329+
* (`default_branch`) and compare (`behind_by`) API fields only — never diff/file content. A branch far behind
330+
* is more likely to hide a subtle semantic conflict a clean `mergeable` check alone would miss. */
331+
export interface StaleBranchFinding {
332+
defaultBranch: string;
333+
behindBy: number;
334+
}
335+
328336
/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */
329337
export interface BriefFindings {
330338
dependency?: DependencyFinding[];
@@ -351,6 +359,7 @@ export interface BriefFindings {
351359
blameLink?: BlameLinkFinding[];
352360
approvalIntegrity?: ApprovalIntegrityFinding[];
353361
ciCheckSignals?: CiCheckSignalFinding[];
362+
staleBranch?: StaleBranchFinding[];
354363
}
355364

356365
/** 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
@@ -34,6 +34,7 @@ const EXPECTED_ANALYZERS = [
3434
"blameLink",
3535
"approvalIntegrity",
3636
"ciCheckSignals",
37+
"staleBranch",
3738
];
3839

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

0 commit comments

Comments
 (0)