Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,19 @@ GITTENSORY_REVIEW_ENRICHMENT=false
# dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol,redos
# provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig,nativeBuild
# history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity,ciCheckSignals
# staleBranch
#
# Profile defaults:
# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild
# balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency
# actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature
# iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink
# approvalIntegrity,ciCheckSignals
# approvalIntegrity,ciCheckSignals,staleBranch
# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
# redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
# nativeBuild,history,docCommentDrift,duplication,churnHotspot,blameLink,approvalIntegrity
# ciCheckSignals
# ciCheckSignals,staleBranch
# END GENERATED REES ANALYZERS

# Submitter-reputation spend control (internal-only): downgrades new/burst/low-rep
Expand Down
22 changes: 22 additions & 0 deletions apps/gittensory-ui/src/lib/rees-analyzers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,28 @@ export const REES_ANALYZERS = [
"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.",
},
},
{
name: "staleBranch",
title: "Stale branch signal",
category: "history",
cost: "github-light",
defaultEnabled: true,
profiles: ["balanced", "deep"],
requires: ["github-token", "head-sha"],
limits: {
behindThreshold: 100,
},
docs: {
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.",
looksAt:
"The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
reports: "The default branch name and the commit count behind it — never commit content.",
network: "Calls the GitHub repo API once and the compare API once.",
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.",
},
},
] as const satisfies readonly ReesAnalyzerDoc[];

export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name);
25 changes: 25 additions & 0 deletions review-enrichment/analyzer-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,31 @@
"network": "Calls the GitHub check-runs API once, bounded to one page.",
"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."
}
},
{
"name": "staleBranch",
"title": "Stale branch signal",
"category": "history",
"cost": "github-light",
"defaultEnabled": true,
"profiles": [
"balanced",
"deep"
],
"requires": [
"github-token",
"head-sha"
],
"limits": {
"behindThreshold": 100
},
"docs": {
"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.",
"looksAt": "The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
"reports": "The default branch name and the commit count behind it — never commit content.",
"network": "Calls the GitHub repo API once and the compare API once.",
"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."
}
}
]
}
31 changes: 31 additions & 0 deletions review-enrichment/src/analyzers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { scanProvenance } from "./provenance.js";
import { scanRedos } from "./redos.js";
import { secretAnalyzer } from "./secret/descriptor.js";
import { scanSecretLog } from "./secret-log.js";
import { scanStaleBranch } from "./stale-branch.js";
import { scanTyposquat } from "./typosquat.js";
import type {
AnalyzerDescriptor,
Expand Down Expand Up @@ -554,6 +555,36 @@ export const ANALYZER_DESCRIPTORS = [
run: (req, { signal, analysis, diagnostics }) =>
scanCiCheckSignals(req, fetch, { signal, analysis, diagnostics }),
}),
descriptor({
name: "staleBranch",
title: "Stale branch signal",
category: "history",
cost: "github-light",
defaultEnabled: true,
requires: ["github-token", "head-sha"],
limits: { behindThreshold: 100 },
docs: {
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.",
looksAt: "The repo's current default branch and how many commits behind it the PR's head is (the GitHub compare API).",
reports: "The default branch name and the commit count behind it — never commit content.",
network: "Calls the GitHub repo API once and the compare API once.",
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.",
},
render: (findings, helpers) => {
if (!findings.length) return [];
const lines = ["### Stale branch signal"];
for (const item of findings) {
lines.push(
`- This PR is ${item.behindBy} commits behind ${helpers.safeCodeSpan(item.defaultBranch)}`,
);
}
return lines;
},
run: (req, { signal, analysis, diagnostics }) =>
scanStaleBranch(req, fetch, { signal, analysis, diagnostics }),
}),
] as const satisfies readonly AnyAnalyzerDescriptor[];

export const ANALYZER_NAMES = ANALYZER_DESCRIPTORS.map(
Expand Down
133 changes: 133 additions & 0 deletions review-enrichment/src/analyzers/stale-branch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Stale-branch signal, read from structured GitHub repo/compare API fields only — no diff/text/log parsing.
// Surfaces a PR whose branch is significantly BEHIND the repo's current default branch — a staleness risk the
// PR page itself does not summarize as a number (GitHub's own UI only shows "This branch is out-of-date", not
// how far). A branch far behind is more likely to hide a subtle semantic conflict a clean `mergeable` check
// would miss. Reads only documented fields from the GitHub repo API (`default_branch`) and the compare API
// (`status`, `behind_by`) — no ambiguous-syntax parsing, so it cannot suffer a patch scanner's edge cases. Pure
// GitHub-metadata read, no repo content. Fail-safe: no token, no head SHA, a bad repo slug, or either fetch
// failing all yield no finding rather than an error.
import type {
AnalyzerDiagnostics,
EnrichRequest,
StaleBranchFinding,
} from "../types.js";
import type { AnalysisContext } from "../analysis-context.js";
import { boundedFetchJson } from "../external-fetch.js";

const GITHUB_API = "https://api.github.com";
const SLUG_RE = /^[A-Za-z0-9._-]+$/;
// Below this many commits behind, drifting from the default branch is normal PR life, not a staleness risk.
const BEHIND_THRESHOLD = 100;

interface ScanOptions {
signal?: AbortSignal;
analysis?: Pick<AnalysisContext, "fetchJson">;
diagnostics?: AnalyzerDiagnostics;
}

interface RepoInfo {
default_branch?: string;
}

interface CompareResult {
status?: string;
behind_by?: number;
}

function githubHeaders(token: string): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
}

async function fetchDefaultBranch(
owner: string,
repo: string,
headers: Record<string, string>,
fetchFn: typeof fetch,
signal: AbortSignal | undefined,
options: Pick<ScanOptions, "analysis" | "diagnostics">,
): Promise<string | null> {
const url = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const fetchOptions = {
endpointCategory: "github-repo-info",
headers,
signal,
fetchImpl: fetchFn,
diagnostics: options.diagnostics,
phase: "stale-branch",
subcall: "github-repo-info",
maxBytes: 128 * 1024,
};
const response = options.analysis
? await options.analysis.fetchJson<RepoInfo>(url, fetchOptions)
: await boundedFetchJson<RepoInfo>(url, fetchOptions);
return response.ok && typeof response.data.default_branch === "string" && response.data.default_branch
? response.data.default_branch
: null;
}

async function fetchCompare(
owner: string,
repo: string,
base: string,
head: string,
headers: Record<string, string>,
fetchFn: typeof fetch,
signal: AbortSignal | undefined,
options: Pick<ScanOptions, "analysis" | "diagnostics">,
): Promise<CompareResult | null> {
const url =
`${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/compare/` +
`${encodeURIComponent(base)}...${encodeURIComponent(head)}`;
const fetchOptions = {
endpointCategory: "github-compare",
headers,
signal,
fetchImpl: fetchFn,
diagnostics: options.diagnostics,
phase: "stale-branch",
subcall: "github-compare",
maxBytes: 256 * 1024,
};
const response = options.analysis
? await options.analysis.fetchJson<CompareResult>(url, fetchOptions)
: await boundedFetchJson<CompareResult>(url, fetchOptions);
return response.ok ? response.data : null;
}

/** Pure: a repo's default branch + a compare-API result → a stale-branch finding, when behind_by crosses the
* fixed threshold. `behind_by` must be a finite non-negative number — a missing/malformed field fails closed
* (no finding) rather than guessing. Pure. */
export function evaluateStaleBranch(defaultBranch: string, compare: CompareResult): StaleBranchFinding[] {
const behindBy = compare.behind_by;
if (typeof behindBy !== "number" || !Number.isFinite(behindBy) || behindBy < 0) return [];
if (behindBy < BEHIND_THRESHOLD) return [];
return [{ defaultBranch, behindBy }];
}

/** Analyzer entrypoint: how far this PR's head is behind the repo's CURRENT default branch → a stale-branch
* finding, when significant. Fail-safe — no token, no head SHA, a bad repo slug, or either fetch failing all
* yield no finding rather than an error. */
export async function scanStaleBranch(
req: EnrichRequest,
fetchFn: typeof fetch = fetch,
options: ScanOptions = {},
): Promise<StaleBranchFinding[]> {
const { repoFullName, githubToken, headSha } = req;
if (!githubToken || !headSha) return [];
const parts = repoFullName.split("/");
const owner = parts[0];
const repo = parts[1];
if (parts.length !== 2 || !owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return [];

const headers = githubHeaders(githubToken);
const defaultBranch = await fetchDefaultBranch(owner, repo, headers, fetchFn, options.signal, options);
if (!defaultBranch) return [];
const compare = await fetchCompare(owner, repo, defaultBranch, headSha, headers, fetchFn, options.signal, options);
if (!compare) return [];

return evaluateStaleBranch(defaultBranch, compare);
}
1 change: 1 addition & 0 deletions review-enrichment/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ export function renderBrief(
lines.push(...renderDescriptorSection("blameLink", findings.blameLink));
lines.push(...renderDescriptorSection("approvalIntegrity", findings.approvalIntegrity));
lines.push(...renderDescriptorSection("ciCheckSignals", findings.ciCheckSignals));
lines.push(...renderDescriptorSection("staleBranch", findings.staleBranch));

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

Expand Down
9 changes: 9 additions & 0 deletions review-enrichment/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,14 @@ export type CiCheckSignalFinding =
| { checkName: string; kind: "retried-after-failure"; failedAttempts: number }
| { checkName: string; kind: "long-running-check"; durationMinutes: number };

/** A PR whose head is significantly behind the repo's CURRENT default branch, read from structured GitHub repo
* (`default_branch`) and compare (`behind_by`) API fields only — never diff/file content. A branch far behind
* is more likely to hide a subtle semantic conflict a clean `mergeable` check alone would miss. */
export interface StaleBranchFinding {
defaultBranch: string;
behindBy: number;
}

/** Structured analyzer output. Each analyzer fills its own key; more land as analyzers ship (#1477/#1478). */
export interface BriefFindings {
dependency?: DependencyFinding[];
Expand All @@ -351,6 +359,7 @@ export interface BriefFindings {
blameLink?: BlameLinkFinding[];
approvalIntegrity?: ApprovalIntegrityFinding[];
ciCheckSignals?: CiCheckSignalFinding[];
staleBranch?: StaleBranchFinding[];
}

/** A JSDoc/TSDoc block whose `@param` tags name parameters the adjacent function no longer declares — a
Expand Down
1 change: 1 addition & 0 deletions review-enrichment/test/analyzer-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const EXPECTED_ANALYZERS = [
"blameLink",
"approvalIntegrity",
"ciCheckSignals",
"staleBranch",
];

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