Skip to content

Commit 5594488

Browse files
author
andriypolandki
committed
Merge branch 'main' into feat/scoring-branch-eligibility-breakdown
2 parents 91aee5d + 537d1d3 commit 5594488

74 files changed

Lines changed: 5213 additions & 363 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,17 @@ GITTENSORY_REVIEW_ENRICHMENT=false
6565
# Current analyzer names:
6666
# dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol,redos
6767
# provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig,nativeBuild
68-
# history,docCommentDrift
68+
# history,docCommentDrift,duplication,churnHotspot
6969
#
7070
# Profile defaults:
7171
# fast: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7272
# redos,provenance,secretLog,typosquat,iacMisconfig,nativeBuild
7373
# balanced (default): dependency,lockfileDrift,secret,license,installScript,heavyDependency
7474
# actionPin,eol,redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature
75-
# iacMisconfig,nativeBuild,history,docCommentDrift
75+
# iacMisconfig,nativeBuild,history,docCommentDrift,duplication,churnHotspot
7676
# deep: dependency,lockfileDrift,secret,license,installScript,heavyDependency,actionPin,eol
7777
# redos,provenance,codeowners,secretLog,assetWeight,typosquat,commitSignature,iacMisconfig
78-
# nativeBuild,history,docCommentDrift
78+
# nativeBuild,history,docCommentDrift,duplication,churnHotspot
7979
# END GENERATED REES ANALYZERS
8080

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

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,60 @@ export const REES_ANALYZERS = [
505505
"Conservative: only named function declarations with confidently-enumerable params; non-parameter signature edits are not reported.",
506506
},
507507
},
508+
{
509+
name: "duplication",
510+
title: "Near-verbatim duplicated code",
511+
category: "quality",
512+
cost: "github-light",
513+
defaultEnabled: true,
514+
profiles: ["balanced", "deep"],
515+
requires: ["files", "github-token", "head-sha"],
516+
limits: {
517+
minRun: 8,
518+
maxCandidates: 40,
519+
maxFetches: 30,
520+
maxFindings: 25,
521+
maxFileBytes: 500000,
522+
},
523+
docs: {
524+
summary:
525+
"Flags added code that is a near-verbatim duplicate of a block already present elsewhere in the repo.",
526+
looksAt:
527+
"Added diff hunks in changed source files compared against same-extension repo files fetched from the git tree at headSha.",
528+
reports:
529+
"The head file:line, the existing source file:line it duplicates, and the matched line count.",
530+
network:
531+
"Calls the GitHub API for the git tree and candidate blobs. Requires headSha and token forwarding for private repos.",
532+
notes:
533+
"Conservative: trivial/boilerplate lines are dropped and a long contiguous run is required, so incidental overlap is not flagged. Never returns code content.",
534+
},
535+
},
536+
{
537+
name: "churnHotspot",
538+
title: "Churn hotspots",
539+
category: "history",
540+
cost: "github-heavy",
541+
defaultEnabled: true,
542+
profiles: ["balanced", "deep"],
543+
requires: ["files", "github-token"],
544+
limits: {
545+
maxFilesProbed: 8,
546+
windowDays: 90,
547+
perPage: 100,
548+
},
549+
docs: {
550+
summary:
551+
"Flags changed files that are statistical fragility hotspots — high commit frequency and a high fix/revert fraction.",
552+
looksAt:
553+
"Each changed file's recent commit history (a 90-day window), excluding lockfiles, generated output, and binaries.",
554+
reports:
555+
"File, commit count, fix/revert count, and the window — counts only, never file contents.",
556+
network:
557+
"Calls the GitHub commits API once per probed file. Requires GitHub token forwarding for private repos.",
558+
notes:
559+
"Distinct from the history analyzer's author track record; this scores the change AREA's defect density.",
560+
},
561+
},
508562
] as const satisfies readonly ReesAnalyzerDoc[];
509563

510564
export const REES_ANALYZER_NAMES = REES_ANALYZERS.map((analyzer) => analyzer.name);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ALTER TABLE github_rate_limit_observations
2+
ADD COLUMN admission_key TEXT;
3+
4+
CREATE INDEX IF NOT EXISTS github_rate_limit_observations_admission_observed_idx
5+
ON github_rate_limit_observations (admission_key, observed_at);

packages/gittensory-mcp/bin/gittensory-mcp.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1404,7 +1404,10 @@ async function runCli(args) {
14041404
if (command === "init-client") return initClient(options);
14051405
if (command === "decision-pack") return decisionPackCli(options);
14061406
if (command === "repo-decision") return repoDecisionCli(options);
1407-
if (command !== "analyze-branch" && command !== "preflight") throw new Error(`Unknown command: ${command}. Run \`gittensory-mcp --help\` to list commands.`);
1407+
if (command !== "analyze-branch" && command !== "preflight") {
1408+
const suggestion = suggestCommand(command);
1409+
throw new Error(`Unknown command: ${command}.${suggestion ? ` Did you mean \`${suggestion}\`?` : ""} Run \`gittensory-mcp --help\` to list commands.`);
1410+
}
14081411
const contributorLogin = options.login ?? process.env.GITTENSORY_LOGIN ?? process.env.GITHUB_LOGIN;
14091412
if (!contributorLogin) throw new Error("Pass --login <github-login> or set GITTENSORY_LOGIN.");
14101413
const result = await analyzeCurrentBranch({
@@ -1685,6 +1688,38 @@ function buildCompletionScript(shell) {
16851688
return buildPowershellCompletion(topLevel, withSubcommands);
16861689
}
16871690

1691+
// Suggest the closest known command for a typo, so an unknown command can offer a "did you mean".
1692+
// Only suggests within a small edit-distance budget that scales with input length, so unrelated
1693+
// input gets no (misleading) suggestion.
1694+
function suggestCommand(input) {
1695+
let best = null;
1696+
let bestDistance = Infinity;
1697+
for (const candidate of Object.keys(CLI_COMMAND_SPEC)) {
1698+
const distance = levenshteinDistance(input, candidate);
1699+
if (distance < bestDistance) {
1700+
bestDistance = distance;
1701+
best = candidate;
1702+
}
1703+
}
1704+
const budget = Math.max(2, Math.floor(input.length / 3));
1705+
return best !== null && bestDistance > 0 && bestDistance <= budget ? best : null;
1706+
}
1707+
1708+
function levenshteinDistance(a, b) {
1709+
if (a.length === 0) return b.length;
1710+
if (b.length === 0) return a.length;
1711+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
1712+
for (let i = 1; i <= a.length; i += 1) {
1713+
const current = [i];
1714+
for (let j = 1; j <= b.length; j += 1) {
1715+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1716+
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
1717+
}
1718+
previous = current;
1719+
}
1720+
return previous[b.length];
1721+
}
1722+
16881723
function buildBashCompletion(topLevel, withSubcommands) {
16891724
const subcommandCases = withSubcommands
16901725
.map(([command, subcommands]) => ` ${command}) COMPREPLY=( $(compgen -W "${subcommands.join(" ")}" -- "$cur") ); return 0;;`)

packages/gittensory-mcp/lib/local-branch.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
66
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
77

88
export function parseGitRemote(remoteUrl) {
9-
const trimmed = String(remoteUrl ?? "").trim();
9+
const trimmed = String(remoteUrl ?? "").trim().replace(/\/+$/, "");
1010
const patterns = [
1111
/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/,
1212
/^https:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/,

review-enrichment/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,33 @@ elapsed time, partial/analyzer status, history lookup counts, GitHub endpoint ca
159159
those fields to spot a broken analyzer without exposing request bodies, diffs, tokens, prompts, comments, or private
160160
config.
161161

162+
### REES Sentry queries
163+
164+
REES keeps its indexed Sentry tags intentionally small and stable:
165+
166+
- `event`
167+
- `route`
168+
- `method`
169+
- `repo`
170+
- `pullNumber`
171+
- `analyzer`
172+
- `release`
173+
- `environment`
174+
- `railwayDeploymentId`
175+
176+
Useful production queries:
177+
178+
- Route exceptions on the enrichment endpoint:
179+
- `event:rees_route_error route:/v1/enrich method:POST`
180+
- Analyzer failures grouped by analyzer:
181+
- `event:rees_analyzer_degraded analyzer:history`
182+
- `event:rees_analyzer_degraded analyzer:dependency repo:JSONbored/gittensory`
183+
- Source-map upload/startup failures on a Railway deploy:
184+
- `event:rees_sourcemap_upload_failed railwayDeploymentId:<deploy-id>`
185+
- Process-level crashes:
186+
- `event:rees_uncaught_exception`
187+
- `event:rees_unhandled_rejection`
188+
162189
If Sentry still shows frames such as `/app/dist/server.js`, check:
163190

164191
1. The event's `release` is `gittensory-rees@<same Railway commit sha>` or your exact `SENTRY_RELEASE` override.

review-enrichment/analyzer-metadata.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,63 @@
579579
"network": "Calls the GitHub API for changed file contents. Requires headSha and token forwarding for private repos.",
580580
"notes": "Conservative: only named function declarations with confidently-enumerable params; non-parameter signature edits are not reported."
581581
}
582+
},
583+
{
584+
"name": "duplication",
585+
"title": "Near-verbatim duplicated code",
586+
"category": "quality",
587+
"cost": "github-light",
588+
"defaultEnabled": true,
589+
"profiles": [
590+
"balanced",
591+
"deep"
592+
],
593+
"requires": [
594+
"files",
595+
"github-token",
596+
"head-sha"
597+
],
598+
"limits": {
599+
"minRun": 8,
600+
"maxCandidates": 40,
601+
"maxFetches": 30,
602+
"maxFindings": 25,
603+
"maxFileBytes": 500000
604+
},
605+
"docs": {
606+
"summary": "Flags added code that is a near-verbatim duplicate of a block already present elsewhere in the repo.",
607+
"looksAt": "Added diff hunks in changed source files compared against same-extension repo files fetched from the git tree at headSha.",
608+
"reports": "The head file:line, the existing source file:line it duplicates, and the matched line count.",
609+
"network": "Calls the GitHub API for the git tree and candidate blobs. Requires headSha and token forwarding for private repos.",
610+
"notes": "Conservative: trivial/boilerplate lines are dropped and a long contiguous run is required, so incidental overlap is not flagged. Never returns code content."
611+
}
612+
},
613+
{
614+
"name": "churnHotspot",
615+
"title": "Churn hotspots",
616+
"category": "history",
617+
"cost": "github-heavy",
618+
"defaultEnabled": true,
619+
"profiles": [
620+
"balanced",
621+
"deep"
622+
],
623+
"requires": [
624+
"files",
625+
"github-token"
626+
],
627+
"limits": {
628+
"maxFilesProbed": 8,
629+
"windowDays": 90,
630+
"perPage": 100
631+
},
632+
"docs": {
633+
"summary": "Flags changed files that are statistical fragility hotspots — high commit frequency and a high fix/revert fraction.",
634+
"looksAt": "Each changed file's recent commit history (a 90-day window), excluding lockfiles, generated output, and binaries.",
635+
"reports": "File, commit count, fix/revert count, and the window — counts only, never file contents.",
636+
"network": "Calls the GitHub commits API once per probed file. Requires GitHub token forwarding for private repos.",
637+
"notes": "Distinct from the history analyzer's author track record; this scores the change AREA's defect density."
638+
}
582639
}
583640
]
584641
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Churn-hotspot analyzer (#1513). For the files a PR changes, reads each file's recent commit history from the
2+
// GitHub API and flags the ones that are statistical fragility hotspots — a high commit frequency AND a high
3+
// fraction of fix/revert commits in the window. These are areas where defects historically cluster, so the
4+
// reviewer should scrutinize the change harder. This is heavy/external/historical analysis the no-checkout
5+
// `claude --print` reviewer cannot do. Surfaces only counts derived from the public commit log — never file
6+
// contents. Distinct from the history analyzer (#1478), which scores the AUTHOR's track record.
7+
import type {
8+
AnalyzerDiagnostics,
9+
EnrichRequest,
10+
ChurnHotspotFinding,
11+
} from "../types.js";
12+
import type { AnalysisContext } from "../analysis-context.js";
13+
import { boundedFetchJson } from "../external-fetch.js";
14+
15+
const GITHUB_API = "https://api.github.com";
16+
const SLUG_RE = /^[A-Za-z0-9._-]+$/;
17+
const WINDOW_DAYS = 90;
18+
const PER_PAGE = 100; // one page; a file with a full page of commits in the window is already a clear hotspot
19+
const MAX_FILES_PROBED = 8; // bound the GitHub round-trips, matching the other history-class analyzers
20+
const MIN_COMMITS = 8; // a hotspot must change frequently within the window
21+
const MIN_FIX_FRACTION = 0.3; // and a meaningful share of those changes must be fixes/reverts
22+
// Files whose commit churn is not a useful code-fragility signal — lockfiles, generated output, and binaries.
23+
const SKIP_RE =
24+
/(?:^|\/)(?: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;
25+
// Defect-correcting commit subjects: fix/bugfix/hotfix/revert/regression (conventional-commit `fix:` included).
26+
const FIX_RE = /\b(?:fix(?:e[ds]|ing)?|bug ?fix|hotfix|revert(?:ed|s)?|regression)\b/i;
27+
28+
interface ScanOptions {
29+
signal?: AbortSignal;
30+
analysis?: Pick<AnalysisContext, "fetchJson">;
31+
diagnostics?: AnalyzerDiagnostics;
32+
}
33+
34+
/** The slice of a GitHub commit-list item this analyzer reads. */
35+
interface CommitItem {
36+
commit?: { message?: string };
37+
}
38+
39+
/** True when a commit's SUBJECT line describes a defect correction. Pure. */
40+
export function isFixCommit(message: string): boolean {
41+
const subject = message.split("\n", 1)[0] ?? "";
42+
return FIX_RE.test(subject);
43+
}
44+
45+
/** Reduce a commit list to total + fix counts, capped flag, and the fix fraction. Pure. */
46+
export function summarizeChurn(commits: CommitItem[]): {
47+
commitCount: number;
48+
fixCount: number;
49+
fixFraction: number;
50+
} {
51+
let fixCount = 0;
52+
for (const item of commits) if (isFixCommit(item.commit?.message ?? "")) fixCount += 1;
53+
const commitCount = commits.length;
54+
return { commitCount, fixCount, fixFraction: commitCount ? fixCount / commitCount : 0 };
55+
}
56+
57+
/** True when a file's churn summary meets the hotspot thresholds (enough commits AND enough of them fixes). Pure. */
58+
export function isHotspot(summary: { commitCount: number; fixFraction: number }): boolean {
59+
return summary.commitCount >= MIN_COMMITS && summary.fixFraction >= MIN_FIX_FRACTION;
60+
}
61+
62+
function githubHeaders(token: string): Record<string, string> {
63+
return {
64+
Authorization: `Bearer ${token}`,
65+
Accept: "application/vnd.github+json",
66+
"X-GitHub-Api-Version": "2022-11-28",
67+
};
68+
}
69+
70+
/** Fetch one page of commits touching `path` since `since`. Returns the list, or null on any error / non-200. */
71+
async function fetchFileCommits(
72+
url: string,
73+
headers: Record<string, string>,
74+
fetchFn: typeof fetch,
75+
signal: AbortSignal | undefined,
76+
options: Pick<ScanOptions, "analysis" | "diagnostics">,
77+
): Promise<CommitItem[] | null> {
78+
const fetchOptions = {
79+
endpointCategory: "github-commits",
80+
headers,
81+
signal,
82+
fetchImpl: fetchFn,
83+
diagnostics: options.diagnostics,
84+
phase: "churn-hotspot",
85+
subcall: "github-commits",
86+
maxBytes: 512 * 1024,
87+
maxCallsPerCategory: MAX_FILES_PROBED,
88+
};
89+
const response = options.analysis
90+
? await options.analysis.fetchJson<CommitItem[]>(url, fetchOptions)
91+
: await boundedFetchJson<CommitItem[]>(url, fetchOptions);
92+
return response.ok && Array.isArray(response.data) ? response.data : null;
93+
}
94+
95+
/** Analyzer entrypoint: changed files → per-file recent commit history → fragility hotspots. Fail-safe. */
96+
export async function scanChurnHotspot(
97+
req: EnrichRequest,
98+
fetchFn: typeof fetch = fetch,
99+
options: ScanOptions = {},
100+
): Promise<ChurnHotspotFinding[]> {
101+
const { repoFullName, githubToken, files = [] } = req;
102+
if (!githubToken) return [];
103+
const [owner, repo] = repoFullName.split("/");
104+
if (!owner || !repo || !SLUG_RE.test(owner) || !SLUG_RE.test(repo)) return [];
105+
106+
const headers = githubHeaders(githubToken);
107+
const since = new Date(Date.now() - WINDOW_DAYS * 86_400_000).toISOString();
108+
// A newly-added file has no prior history; skip it (and non-code/generated files) before spending a round-trip.
109+
const paths = files
110+
.filter((file) => file.status !== "added" && !SKIP_RE.test(file.path))
111+
.map((file) => file.path)
112+
.slice(0, MAX_FILES_PROBED);
113+
114+
const findings: ChurnHotspotFinding[] = [];
115+
for (const path of paths) {
116+
if (options.signal?.aborted) break;
117+
const url =
118+
`${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits` +
119+
`?path=${encodeURIComponent(path)}&since=${encodeURIComponent(since)}&per_page=${PER_PAGE}`;
120+
const commits = await fetchFileCommits(url, headers, fetchFn, options.signal, options);
121+
if (!commits) continue;
122+
const summary = summarizeChurn(commits);
123+
if (!isHotspot(summary)) continue;
124+
findings.push({
125+
file: path,
126+
commitCount: summary.commitCount,
127+
fixCount: summary.fixCount,
128+
windowDays: WINDOW_DAYS,
129+
capped: summary.commitCount >= PER_PAGE,
130+
});
131+
}
132+
return findings;
133+
}

0 commit comments

Comments
 (0)