feat(FN-4777): complete Step 2 — add tracking dedup helper module
Fusion-Task-Id: FN-4777 Fusion-Task-Lineage: ed00f639-c928-4c14-be1f-2b43db6022c7
This commit is contained in:
committed by
gsxdsm
parent
a6b1d59a0c
commit
de5a5c91d4
129
packages/dashboard/src/github-tracking-dedup.ts
Normal file
129
packages/dashboard/src/github-tracking-dedup.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user