Files
fusion/packages/dashboard/src/github-tracking-dedup.ts
Fusion (runfusion.ai) d89a4569f1 fix(FN-4777): add dedup default setting and lint-safe regex
Fusion-Task-Id: FN-4777
Fusion-Task-Lineage: ed00f639-c928-4c14-be1f-2b43db6022c7
2026-05-16 12:56:31 -07:00

130 lines
4.0 KiB
TypeScript

import type { Task } from "@fusion/core";
const FILE_SCOPE_HEADER = /^##\s+File\s+Scope\s*$/im;
const FILE_SCOPE_BULLET = /^-\s+`?([^`\n]+?)`?\s*$/;
const BACKTICK_IDENTIFIER_RE = /`([A-Za-z0-9_./-]{3,})`/g;
const ERROR_NAME_RE = /\b([A-Z][A-Za-z0-9]*(?:Error|Exception))\b/g;
const SYMBOL_TOKEN_RE = /\b([A-Za-z_][A-Za-z0-9_]{5,}|[a-z]+(?:[A-Z][A-Za-z0-9]+){2,})\b/g;
const STOPWORDS = new Set([
"fusion",
"task",
"tasks",
"issue",
"github",
"tracking",
"bug",
"fix",
"error",
"failed",
"failure",
"create",
"update",
"description",
"scope",
]);
export const DEDUP_MATCH_THRESHOLD = 3;
function stripTrailingGlob(path: string): string {
return path.replace(/\/+$/, "").replace(/\/(\*\*?|[^/]*[?*][^/]*)$/, "");
}
function quoteIfNeeded(value: string): string {
return /[./-]/.test(value) ? `"${value}"` : value;
}
function toSearchText(candidate: { title: string; body: string | null }): string {
return `${candidate.title}\n${candidate.body ?? ""}`.toLowerCase();
}
function escapeRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function extractFileScopePaths(task: Pick<Task, "description"> & { prompt?: string }): string[] {
const source = task.prompt ?? task.description ?? "";
const headerMatch = source.match(FILE_SCOPE_HEADER);
if (!headerMatch || headerMatch.index == null) {
return [];
}
const sectionStart = headerMatch.index + headerMatch[0].length;
const afterStart = source.slice(sectionStart);
const nextHeaderMatch = afterStart.match(/\n##\s+/);
const section = nextHeaderMatch ? afterStart.slice(0, nextHeaderMatch.index) : afterStart;
const paths: string[] = [];
const seen = new Set<string>();
for (const rawLine of section.split("\n")) {
const line = rawLine.trim();
if (!line.startsWith("- ")) continue;
const match = line.match(FILE_SCOPE_BULLET);
if (!match) continue;
const normalized = stripTrailingGlob(match[1].trim());
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
paths.push(normalized);
if (paths.length >= 8) break;
}
return paths;
}
export function extractSymptomKeywords(task: Pick<Task, "title" | "description">, opts?: { max?: number }): string[] {
const max = Math.max(1, opts?.max ?? 6);
const source = `${task.title ?? ""}\n${task.description ?? ""}`;
const values: string[] = [];
const seen = new Set<string>();
for (const regex of [BACKTICK_IDENTIFIER_RE, ERROR_NAME_RE, SYMBOL_TOKEN_RE]) {
regex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(source)) && values.length < max) {
const token = match[1].trim();
const normalized = token.toLowerCase();
if (token.length < 6) continue;
if (STOPWORDS.has(normalized)) continue;
if (seen.has(normalized)) continue;
seen.add(normalized);
values.push(token);
}
if (values.length >= max) break;
}
return values;
}
export function buildIssueSearchQueries(paths: string[], keywords: string[]): string[] {
const queries: string[] = [];
const topKeywords = keywords.slice(0, 3);
if (topKeywords.length > 0) {
queries.push(topKeywords.map((keyword) => quoteIfNeeded(keyword)).join(" "));
}
for (const path of paths.slice(0, 2)) {
queries.push(quoteIfNeeded(path));
}
return queries.slice(0, 3);
}
export function scoreCandidateIssue(
candidate: { title: string; body: string | null },
paths: string[],
keywords: string[],
): { score: number; matchedPaths: string[]; matchedKeywords: string[] } {
const text = toSearchText(candidate);
const matchedPaths = paths.filter((path) => text.includes(path.toLowerCase()));
const matchedKeywords = keywords.filter((keyword) => {
const pattern = new RegExp(`\\b${escapeRegex(keyword)}\\b`, "i");
return pattern.test(`${candidate.title}\n${candidate.body ?? ""}`);
});
return {
score: (matchedPaths.length * 2) + matchedKeywords.length,
matchedPaths,
matchedKeywords,
};
}