Skip to content
Merged
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
10 changes: 10 additions & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,16 @@ export {
// live gate does), not this file's full internal surface.
export { buildCollisionReport, type CollisionCluster, type CollisionReport } from "./signals/predicted-gate-engine.js";
export type { CollisionItem } from "./types/predicted-gate-types.js";
// Unlinked-issue candidate pre-filter (#4883), extracted out of src/signals/unlinked-issue-candidates.ts so the
// miner's self-review can run the SAME deterministic recall pass the maintainer gate uses to flag a PR's
// likely-but-unlinked issue, instead of a driftable copy. PURE — no IO, no AI call.
export {
findUnlinkedIssueCandidates,
MAX_CANDIDATES,
type CandidateOpenIssue,
type FindUnlinkedIssueCandidatesInput,
type UnlinkedIssueCandidateMatch,
} from "./signals/unlinked-issue-candidates.js";
export * from "./plan-export.js";
export { countPlanStepsByStatus } from "./plan-step-stats.js";
export { countPlanSteps } from "./plan-step-count.js";
Expand Down
111 changes: 111 additions & 0 deletions packages/loopover-engine/src/signals/unlinked-issue-candidates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Deterministic pre-filter for the unlinked-issue guardrail (#unlinked-issue-guardrail). PURE — no IO, no
// AI call — so it can run on every unlinked PR for free and only hand a SHORT, bounded candidate list to the
// expensive AI verifier (src/review/unlinked-issue-match.ts), which is the actual precision gate. This stage
// is deliberately RECALL-oriented (a coincidental token/path overlap is cheap to false-positive here — the AI
// step is what must be accurate), never the reverse: it must never silently drop a genuinely-matching issue
// just to save an AI call.
//
// Extracted out of `src/signals/unlinked-issue-candidates.ts` into the shared engine (#4883) so the published
// gittensory-miner/gittensory-mcp CLIs can run the identical recall pass the maintainer gate uses, instead of
// a driftable second copy; `src/signals/unlinked-issue-candidates.ts` is now a thin re-export shim (imported
// via relative source path, matching this repo's existing engine-consumption convention — see src/signals/slop.ts).

export type CandidateOpenIssue = {
number: number;
title: string;
body: string | null;
labels: string[];
};

export type UnlinkedIssueCandidateMatch = {
issue: CandidateOpenIssue;
score: number;
matchedTokens: string[];
pathMentioned: boolean;
};

export type FindUnlinkedIssueCandidatesInput = {
prTitle: string;
prBody: string | null | undefined;
changedPaths: string[];
openIssues: CandidateOpenIssue[];
};

// Bound the AI-verifier fan-out per PR: even a repo with hundreds of open issues only ever sends its
// top-scoring handful for a real (paid/self-host-compute) AI call. Exported so the guardrail orchestrator
// (unlinked-issue-guardrail.ts, #4515) can size its own worst-case per-PR AI-spend estimate off the same
// number, rather than a second, driftable copy of this constant.
export const MAX_CANDIDATES = 3;
// A path/basename mention is a much stronger signal than shared vocabulary — worth several tokens' score,
// and (deliberately) enough on its own to qualify a candidate even with zero token overlap (an issue that
// names the exact file this PR touches is worth checking regardless of shared wording).
const PATH_MENTION_SCORE_BONUS = 5;
// Token overlap alone only qualifies a candidate once it clears this bar — a single shared common word
// (even after stopword filtering) is not enough evidence to spend an AI call on.
const MIN_TOKEN_OVERLAP = 3;
// Tokens shorter than this are dropped before counting — short tokens (case IDs, "PR", "fix") are too
// common across unrelated issues to be distinctive evidence of a real match.
const MIN_TOKEN_LENGTH = 4;

// A small, curated stopword list for the vocabulary shared by nearly every PR/issue description
// regardless of topic — without this, "this PR fixes the issue where..." style boilerplate would dominate
// the token-overlap score and swamp genuinely distinctive words.
const STOPWORDS = new Set([
"this", "that", "with", "from", "have", "when", "where", "which", "there", "their",
"issue", "issues", "should", "would", "could", "about", "into", "your", "were",
"then", "than", "will", "does", "doesn", "cannot", "currently", "instead", "because",
"these", "those", "being", "only", "also", "still", "even", "some", "each", "such",
]);

function tokenize(text: string): Set<string> {
const tokens = text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length >= MIN_TOKEN_LENGTH && !STOPWORDS.has(token));
return new Set(tokens);
}

/** True when an issue's body names one of the PR's changed files — either the full repo-relative path or
* just its basename (issues commonly reference "the X.ts file" without the full path). Basenames shorter
* than {@link MIN_TOKEN_LENGTH} are skipped as too generic (e.g. `db.ts`, `index.ts` collide across repos).
* The full-path check stays a plain substring match (a repo-relative path is already distinctive enough
* that a coincidental false positive is not realistic). The basename check instead matches against
* path-like TOKENS extracted from the body, requiring an exact token match (or a longer path token ending
* in `/basename`) rather than raw substring containment — a naive `.includes()` would let a basename like
* `reader.ts` match inside an unrelated, longer filename such as `csv-reader.ts`. */
function issueMentionsChangedPath(issueBody: string, changedPaths: string[]): boolean {
const lowerBody = issueBody.toLowerCase();
const bodyPathTokens = (lowerBody.match(/[a-z0-9_\-./]+/g) ?? []).map((token) => token.replace(/\.+$/g, ""));
return changedPaths.some((path) => {
const lowerPath = path.toLowerCase();
if (lowerBody.includes(lowerPath)) return true;
const basename = lowerPath.slice(lowerPath.lastIndexOf("/") + 1);
if (basename.length < MIN_TOKEN_LENGTH) return false;
return bodyPathTokens.some((token) => token === basename || token.endsWith(`/${basename}`));
});
}

/**
* Rank a repo's open issues by how strongly they overlap an unlinked PR, returning at most
* {@link MAX_CANDIDATES} qualifying matches (highest score first, ties broken by lower issue number —
* the earlier-filed issue is the more likely original target). An issue qualifies via EITHER a
* distinctive-token overlap clearing {@link MIN_TOKEN_OVERLAP}, OR a changed-path mention in its body
* (see {@link issueMentionsChangedPath}) — either alone is sufficient. Returns `[]` when nothing qualifies;
* this function never calls out to AI or GitHub, so a repo with no genuine candidates costs nothing beyond
* this pass.
*/
export function findUnlinkedIssueCandidates(input: FindUnlinkedIssueCandidatesInput): UnlinkedIssueCandidateMatch[] {
const prTokens = tokenize(`${input.prTitle} ${input.prBody ?? ""}`);
const matches: UnlinkedIssueCandidateMatch[] = [];
for (const issue of input.openIssues) {
const issueBody = issue.body ?? "";
const issueTokens = tokenize(`${issue.title} ${issueBody}`);
const matchedTokens = [...prTokens].filter((token) => issueTokens.has(token));
const pathMentioned = issueBody.length > 0 && issueMentionsChangedPath(issueBody, input.changedPaths);
if (matchedTokens.length < MIN_TOKEN_OVERLAP && !pathMentioned) continue;
const score = matchedTokens.length + (pathMentioned ? PATH_MENTION_SCORE_BONUS : 0);
matches.push({ issue, score, matchedTokens, pathMentioned });
}
matches.sort((a, b) => b.score - a.score || a.issue.number - b.issue.number);
return matches.slice(0, MAX_CANDIDATES);
}
114 changes: 8 additions & 106 deletions src/signals/unlinked-issue-candidates.ts
Original file line number Diff line number Diff line change
@@ -1,106 +1,8 @@
// Deterministic pre-filter for the unlinked-issue guardrail (#unlinked-issue-guardrail). PURE — no IO, no
// AI call — so it can run on every unlinked PR for free and only hand a SHORT, bounded candidate list to the
// expensive AI verifier (src/review/unlinked-issue-match.ts), which is the actual precision gate. This stage
// is deliberately RECALL-oriented (a coincidental token/path overlap is cheap to false-positive here — the AI
// step is what must be accurate), never the reverse: it must never silently drop a genuinely-matching issue
// just to save an AI call.

export type CandidateOpenIssue = {
number: number;
title: string;
body: string | null;
labels: string[];
};

export type UnlinkedIssueCandidateMatch = {
issue: CandidateOpenIssue;
score: number;
matchedTokens: string[];
pathMentioned: boolean;
};

export type FindUnlinkedIssueCandidatesInput = {
prTitle: string;
prBody: string | null | undefined;
changedPaths: string[];
openIssues: CandidateOpenIssue[];
};

// Bound the AI-verifier fan-out per PR: even a repo with hundreds of open issues only ever sends its
// top-scoring handful for a real (paid/self-host-compute) AI call. Exported so the guardrail orchestrator
// (unlinked-issue-guardrail.ts, #4515) can size its own worst-case per-PR AI-spend estimate off the same
// number, rather than a second, driftable copy of this constant.
export const MAX_CANDIDATES = 3;
// A path/basename mention is a much stronger signal than shared vocabulary — worth several tokens' score,
// and (deliberately) enough on its own to qualify a candidate even with zero token overlap (an issue that
// names the exact file this PR touches is worth checking regardless of shared wording).
const PATH_MENTION_SCORE_BONUS = 5;
// Token overlap alone only qualifies a candidate once it clears this bar — a single shared common word
// (even after stopword filtering) is not enough evidence to spend an AI call on.
const MIN_TOKEN_OVERLAP = 3;
// Tokens shorter than this are dropped before counting — short tokens (case IDs, "PR", "fix") are too
// common across unrelated issues to be distinctive evidence of a real match.
const MIN_TOKEN_LENGTH = 4;

// A small, curated stopword list for the vocabulary shared by nearly every PR/issue description
// regardless of topic — without this, "this PR fixes the issue where..." style boilerplate would dominate
// the token-overlap score and swamp genuinely distinctive words.
const STOPWORDS = new Set([
"this", "that", "with", "from", "have", "when", "where", "which", "there", "their",
"issue", "issues", "should", "would", "could", "about", "into", "your", "were",
"then", "than", "will", "does", "doesn", "cannot", "currently", "instead", "because",
"these", "those", "being", "only", "also", "still", "even", "some", "each", "such",
]);

function tokenize(text: string): Set<string> {
const tokens = text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length >= MIN_TOKEN_LENGTH && !STOPWORDS.has(token));
return new Set(tokens);
}

/** True when an issue's body names one of the PR's changed files — either the full repo-relative path or
* just its basename (issues commonly reference "the X.ts file" without the full path). Basenames shorter
* than {@link MIN_TOKEN_LENGTH} are skipped as too generic (e.g. `db.ts`, `index.ts` collide across repos).
* The full-path check stays a plain substring match (a repo-relative path is already distinctive enough
* that a coincidental false positive is not realistic). The basename check instead matches against
* path-like TOKENS extracted from the body, requiring an exact token match (or a longer path token ending
* in `/basename`) rather than raw substring containment — a naive `.includes()` would let a basename like
* `reader.ts` match inside an unrelated, longer filename such as `csv-reader.ts`. */
function issueMentionsChangedPath(issueBody: string, changedPaths: string[]): boolean {
const lowerBody = issueBody.toLowerCase();
const bodyPathTokens = (lowerBody.match(/[a-z0-9_\-./]+/g) ?? []).map((token) => token.replace(/\.+$/g, ""));
return changedPaths.some((path) => {
const lowerPath = path.toLowerCase();
if (lowerBody.includes(lowerPath)) return true;
const basename = lowerPath.slice(lowerPath.lastIndexOf("/") + 1);
if (basename.length < MIN_TOKEN_LENGTH) return false;
return bodyPathTokens.some((token) => token === basename || token.endsWith(`/${basename}`));
});
}

/**
* Rank a repo's open issues by how strongly they overlap an unlinked PR, returning at most
* {@link MAX_CANDIDATES} qualifying matches (highest score first, ties broken by lower issue number —
* the earlier-filed issue is the more likely original target). An issue qualifies via EITHER a
* distinctive-token overlap clearing {@link MIN_TOKEN_OVERLAP}, OR a changed-path mention in its body
* (see {@link issueMentionsChangedPath}) — either alone is sufficient. Returns `[]` when nothing qualifies;
* this function never calls out to AI or GitHub, so a repo with no genuine candidates costs nothing beyond
* this pass.
*/
export function findUnlinkedIssueCandidates(input: FindUnlinkedIssueCandidatesInput): UnlinkedIssueCandidateMatch[] {
const prTokens = tokenize(`${input.prTitle} ${input.prBody ?? ""}`);
const matches: UnlinkedIssueCandidateMatch[] = [];
for (const issue of input.openIssues) {
const issueBody = issue.body ?? "";
const issueTokens = tokenize(`${issue.title} ${issueBody}`);
const matchedTokens = [...prTokens].filter((token) => issueTokens.has(token));
const pathMentioned = issueBody.length > 0 && issueMentionsChangedPath(issueBody, input.changedPaths);
if (matchedTokens.length < MIN_TOKEN_OVERLAP && !pathMentioned) continue;
const score = matchedTokens.length + (pathMentioned ? PATH_MENTION_SCORE_BONUS : 0);
matches.push({ issue, score, matchedTokens, pathMentioned });
}
matches.sort((a, b) => b.score - a.score || a.issue.number - b.issue.number);
return matches.slice(0, MAX_CANDIDATES);
}
// Unlinked-issue candidate pre-filter, extracted to `@loopover/engine` (#4883) so the published
// gittensory-mcp/gittensory-miner CLIs can run the SAME deterministic recall pass the maintainer gate uses to
// surface a PR's likely-but-unlinked issue, instead of reaching into this backend's src/ tree. This file is a
// thin re-export shim; the implementation lives at packages/loopover-engine/src/signals/unlinked-issue-candidates.ts
// (imported via relative source path, not the published package, to match this repo's existing
// engine-consumption convention — see e.g. src/signals/slop.ts — and to avoid depending on the engine
// package's built dist/ output, which is not guaranteed to exist yet when typecheck/test:coverage run in CI).
export * from "../../packages/loopover-engine/src/signals/unlinked-issue-candidates";