feat(FN-4918): merge fusion/fn-4918
This commit is contained in:
@@ -1,10 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeContentFingerprint,
|
||||
findDuplicateMatches,
|
||||
type DuplicateCandidate,
|
||||
} from "../duplicate-detection.js";
|
||||
|
||||
describe("computeContentFingerprint", () => {
|
||||
it("returns identical fingerprint for identical title and description", () => {
|
||||
const first = computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: "Warn before creating duplicate tasks",
|
||||
});
|
||||
const second = computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: "Warn before creating duplicate tasks",
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("normalizes whitespace, casing, and trailing punctuation", () => {
|
||||
const first = computeContentFingerprint({
|
||||
title: "Duplicate Warning Route",
|
||||
description: "Warn before creating duplicate tasks!!!",
|
||||
});
|
||||
const second = computeContentFingerprint({
|
||||
title: " duplicate warning route ",
|
||||
description: "warn before creating duplicate tasks",
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("returns different fingerprints for different titles", () => {
|
||||
const first = computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: "Warn before creating duplicate tasks",
|
||||
});
|
||||
const second = computeContentFingerprint({
|
||||
title: "Retry counter badge placement",
|
||||
description: "Warn before creating duplicate tasks",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("returns null for empty or whitespace-only descriptions", () => {
|
||||
expect(
|
||||
computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: " ",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
computeContentFingerprint({
|
||||
title: "Duplicate warning route",
|
||||
description: "...",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("matches the FN-4909/FN-4910 reproduction pair", () => {
|
||||
const first = computeContentFingerprint({
|
||||
title: "Move retry counter badge next to GitHub tracking badge",
|
||||
description:
|
||||
"Move the retry counter badge to the left of the GitHub tracking badge",
|
||||
});
|
||||
const second = computeContentFingerprint({
|
||||
title: "Move retry counter badge next to GitHub tracking badge",
|
||||
description:
|
||||
"Move the retry counter badge to the left of the GitHub tracking badge.",
|
||||
});
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDuplicateMatches", () => {
|
||||
it("returns high-similarity title+description matches", () => {
|
||||
const candidates: DuplicateCandidate[] = [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { Column } from "./types.js";
|
||||
|
||||
export interface DuplicateMatch {
|
||||
@@ -20,6 +22,11 @@ export interface DuplicateCandidate {
|
||||
column: Column;
|
||||
}
|
||||
|
||||
export interface ContentFingerprintInput {
|
||||
title?: string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLD = 0.45;
|
||||
const DEFAULT_LIMIT = 5;
|
||||
const DEFAULT_EXCLUDE_COLUMNS: Column[] = ["done", "archived"];
|
||||
@@ -39,6 +46,34 @@ const STOPWORDS = new Set([
|
||||
"fn",
|
||||
]);
|
||||
|
||||
function normalizeFingerprintPart(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[,.;:!?"'`(){}]+/g, "")
|
||||
.replaceAll("[", "")
|
||||
.replaceAll("]", "")
|
||||
.replace(/…$/u, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic dedup fingerprint for task content.
|
||||
*/
|
||||
export function computeContentFingerprint(
|
||||
input: ContentFingerprintInput,
|
||||
): string | null {
|
||||
const normalizedDescription = normalizeFingerprintPart(input.description);
|
||||
if (normalizedDescription.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTitle = normalizeFingerprintPart(input.title ?? "");
|
||||
return createHash("sha256")
|
||||
.update(`${normalizedTitle}\n${normalizedDescription}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
|
||||
@@ -131,7 +131,9 @@ export {
|
||||
SelfDefeatingDependencyError,
|
||||
} from "./store.js";
|
||||
export {
|
||||
computeContentFingerprint,
|
||||
findDuplicateMatches,
|
||||
type ContentFingerprintInput,
|
||||
type DuplicateCandidate,
|
||||
type DuplicateMatch,
|
||||
type DuplicateMatchInput,
|
||||
|
||||
@@ -4020,6 +4020,33 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return limit >= 0 ? matches.slice(0, limit) : matches;
|
||||
}
|
||||
|
||||
async findRecentTasksByContentFingerprint(
|
||||
fingerprint: string,
|
||||
options?: { windowMs?: number; includeArchived?: boolean },
|
||||
): Promise<Task[]> {
|
||||
const trimmedFingerprint = fingerprint.trim();
|
||||
if (trimmedFingerprint.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const requestedWindowMs = options?.windowMs ?? 60_000;
|
||||
const windowMs = Math.max(1, Math.min(300_000, Math.trunc(requestedWindowMs)));
|
||||
const cutoffIso = new Date(Date.now() - windowMs).toISOString();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const selectClause = this.getTaskSelectClause(false, "t");
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT ${selectClause}
|
||||
FROM tasks t
|
||||
WHERE json_extract(t.sourceMetadata, '$.contentFingerprint') = ?
|
||||
AND t.createdAt >= ?
|
||||
${includeArchived ? "" : "AND t.\"column\" != 'archived'"}
|
||||
ORDER BY t.createdAt ASC
|
||||
`).all(trimmedFingerprint, cutoffIso) as TaskRow[];
|
||||
|
||||
return rows.map((row) => this.rowToTask(row));
|
||||
}
|
||||
|
||||
async getTasksByAssignedAgent(
|
||||
agentId: string,
|
||||
options?: { pausedOnly?: boolean; excludeArchived?: boolean },
|
||||
|
||||
@@ -875,6 +875,7 @@ export type ActivityEventType =
|
||||
| "task:merged"
|
||||
| "task:failed"
|
||||
| "task:duplicate-warning-overridden"
|
||||
| "task:auto-archived-deterministic-duplicate"
|
||||
| "task:auto-archived-ghost-bug"
|
||||
| "task:auto-archived-duplicate"
|
||||
| "settings:updated"
|
||||
|
||||
Reference in New Issue
Block a user