feat(forensics): opt-in duplicate detection before issue creation (#2105)
* feat(forensics): opt-in duplicate detection before issue creation Adds forensics_dedup preference (default: false) that instructs the forensics agent to search existing issues and PRs before filing. First-time users see an opt-in notice explaining the token cost. Fixes #2096 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: retrigger checks * fix(build): summary must be string[] not string in showNextAction --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
75d2ea7fb7
commit
d83000d05d
5 changed files with 146 additions and 0 deletions
|
|
@ -30,6 +30,9 @@ import { loadPrompt } from "./prompt-loader.js";
|
|||
import { gsdRoot } from "./paths.js";
|
||||
import { formatDuration } from "../shared/format-utils.js";
|
||||
import { getAutoWorktreePath } from "./auto-worktree.js";
|
||||
import { loadEffectiveGSDPreferences, loadGlobalGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
|
||||
import { showNextAction } from "../shared/tui.js";
|
||||
import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -67,6 +70,71 @@ interface ForensicReport {
|
|||
recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[];
|
||||
}
|
||||
|
||||
// ─── Duplicate Detection ──────────────────────────────────────────────────────
|
||||
|
||||
const DEDUP_PROMPT_SECTION = `
|
||||
## Duplicate Detection (REQUIRED before issue creation)
|
||||
|
||||
Before offering to create a GitHub issue, you MUST search for existing issues and PRs that may already address this bug. This step uses the user's AI tokens for analysis.
|
||||
|
||||
### Search Steps
|
||||
|
||||
1. **Search closed issues** for similar keywords from your diagnosis:
|
||||
\`\`\`
|
||||
gh issue list --repo gsd-build/gsd-2 --state closed --search "<keywords from root cause>" --limit 20
|
||||
\`\`\`
|
||||
|
||||
2. **Search open PRs** that might contain the fix:
|
||||
\`\`\`
|
||||
gh pr list --repo gsd-build/gsd-2 --state open --search "<keywords>" --limit 10
|
||||
\`\`\`
|
||||
|
||||
3. **Search merged PRs** that may have already fixed this:
|
||||
\`\`\`
|
||||
gh pr list --repo gsd-build/gsd-2 --state merged --search "<keywords>" --limit 10
|
||||
\`\`\`
|
||||
|
||||
### Analysis
|
||||
|
||||
For each result, compare it against your root-cause diagnosis:
|
||||
- Does the issue describe the same code path or file?
|
||||
- Does the PR modify the same file:line you identified?
|
||||
- Is the symptom description semantically similar even if keywords differ?
|
||||
|
||||
### Present Findings
|
||||
|
||||
If you find potential matches, present them to the user:
|
||||
|
||||
1. **"Already fixed by PR #X — skip issue creation"** — when a merged PR or closed issue clearly addresses the same root cause. Explain why you believe it matches.
|
||||
2. **"Add my findings to existing issue #Y"** — when an open issue exists for the same bug. Use \`gh issue comment #Y --repo gsd-build/gsd-2\` to add forensic evidence.
|
||||
3. **"Create new issue anyway"** — when existing results do not cover this specific failure.
|
||||
|
||||
Only proceed to issue creation if no matches were found OR the user explicitly chooses "Create new issue anyway".
|
||||
`;
|
||||
|
||||
async function writeForensicsDedupPref(ctx: ExtensionCommandContext, enabled: boolean): Promise<void> {
|
||||
const prefsPath = getGlobalGSDPreferencesPath();
|
||||
await ensurePreferencesFile(prefsPath, ctx, "global");
|
||||
const existing = loadGlobalGSDPreferences();
|
||||
const prefs: Record<string, unknown> = existing?.preferences ? { ...existing.preferences } : {};
|
||||
prefs.version = prefs.version || 1;
|
||||
prefs.forensics_dedup = enabled;
|
||||
|
||||
const frontmatter = serializePreferencesToFrontmatter(prefs);
|
||||
const raw = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : "";
|
||||
let body = "\n# GSD Skill Preferences\n\nSee `~/.gsd/agent/extensions/gsd/docs/preferences-reference.md` for full field documentation and examples.\n";
|
||||
const start = raw.startsWith("---\n") ? 4 : raw.startsWith("---\r\n") ? 5 : -1;
|
||||
if (start !== -1) {
|
||||
const closingIdx = raw.indexOf("\n---", start);
|
||||
if (closingIdx !== -1) {
|
||||
const after = raw.slice(closingIdx + 4);
|
||||
if (after.trim()) body = after;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(prefsPath, `---\n${frontmatter}---${body}`, "utf-8");
|
||||
}
|
||||
|
||||
// ─── Entry Point ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleForensics(
|
||||
|
|
@ -98,6 +166,29 @@ export async function handleForensics(
|
|||
return;
|
||||
}
|
||||
|
||||
// ─── Duplicate detection opt-in ─────────────────────────────────────────────
|
||||
const effectivePrefs = loadEffectiveGSDPreferences()?.preferences;
|
||||
let dedupEnabled = effectivePrefs?.forensics_dedup === true;
|
||||
|
||||
if (effectivePrefs?.forensics_dedup === undefined) {
|
||||
const choice = await showNextAction(ctx, {
|
||||
title: "Duplicate detection available",
|
||||
summary: ["Before filing a GitHub issue, forensics can search existing issues and PRs to avoid duplicates.", "This uses additional AI tokens for analysis."],
|
||||
actions: [
|
||||
{ id: "enable", label: "Enable duplicate detection", description: "Search issues/PRs before filing (recommended)", recommended: true },
|
||||
{ id: "skip", label: "Skip for now", description: "File without checking for duplicates" },
|
||||
],
|
||||
notYetMessage: "You can enable this later via preferences (forensics_dedup: true).",
|
||||
});
|
||||
|
||||
if (choice === "enable") {
|
||||
await writeForensicsDedupPref(ctx, true);
|
||||
dedupEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const dedupSection = dedupEnabled ? DEDUP_PROMPT_SECTION : "";
|
||||
|
||||
ctx.ui.notify("Building forensic report...", "info");
|
||||
|
||||
const report = await buildForensicReport(basePath);
|
||||
|
|
@ -117,6 +208,7 @@ export async function handleForensics(
|
|||
problemDescription,
|
||||
forensicData,
|
||||
gsdSourceDir,
|
||||
dedupSection,
|
||||
});
|
||||
|
||||
ctx.ui.notify(`Forensic report saved: ${relative(basePath, savedPath)}`, "info");
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"reactive_execution",
|
||||
"github",
|
||||
"service_tier",
|
||||
"forensics_dedup",
|
||||
]);
|
||||
|
||||
/** Canonical list of all dispatch unit types. */
|
||||
|
|
@ -223,6 +224,8 @@ export interface GSDPreferences {
|
|||
github?: GitHubSyncConfig;
|
||||
/** OpenAI service tier preference. "priority" = 2x cost, faster. "flex" = 0.5x cost, slower. Only affects gpt-5.4 models. */
|
||||
service_tier?: "priority" | "flex";
|
||||
/** Opt-in: search existing issues and PRs before filing from /gsd forensics. Uses additional AI tokens. */
|
||||
forensics_dedup?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadedGSDPreferences {
|
||||
|
|
|
|||
|
|
@ -341,6 +341,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
|
|||
? { ...(base.github ?? {}), ...(override.github ?? {}) } as import("../github-sync/types.js").GitHubSyncConfig
|
||||
: undefined,
|
||||
service_tier: override.service_tier ?? base.service_tier,
|
||||
forensics_dedup: override.forensics_dedup ?? base.forensics_dedup,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ Explain your findings:
|
|||
- **Code snippet** — the problematic code and what it should do instead
|
||||
- **Recovery** — what the user can do right now to get unstuck
|
||||
|
||||
{{dedupSection}}
|
||||
|
||||
Then **offer GitHub issue creation**: "Would you like me to create a GitHub issue for this on gsd-build/gsd-2?"
|
||||
|
||||
**CRITICAL: The `github_issues` tool ONLY targets the current user's repository — it has no `repo` parameter. You MUST use `gh issue create --repo gsd-build/gsd-2` via the `bash` tool to file on the correct repo. Do NOT use the `github_issues` tool for this.**
|
||||
|
|
|
|||
48
src/resources/extensions/gsd/tests/forensics-dedup.test.ts
Normal file
48
src/resources/extensions/gsd/tests/forensics-dedup.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const gsdDir = join(__dirname, "..");
|
||||
|
||||
describe("forensics dedup (#2096)", () => {
|
||||
it("forensics_dedup is in KNOWN_PREFERENCE_KEYS", () => {
|
||||
const source = readFileSync(join(gsdDir, "preferences-types.ts"), "utf-8");
|
||||
assert.ok(source.includes('"forensics_dedup"'),
|
||||
"KNOWN_PREFERENCE_KEYS must contain forensics_dedup");
|
||||
assert.ok(source.includes("forensics_dedup?: boolean"),
|
||||
"GSDPreferences must declare forensics_dedup as optional boolean");
|
||||
});
|
||||
|
||||
it("forensics prompt contains {{dedupSection}} placeholder", () => {
|
||||
const prompt = readFileSync(join(gsdDir, "prompts", "forensics.md"), "utf-8");
|
||||
assert.ok(prompt.includes("{{dedupSection}}"),
|
||||
"forensics.md must contain {{dedupSection}} placeholder");
|
||||
});
|
||||
|
||||
it("DEDUP_PROMPT_SECTION contains required search commands", async () => {
|
||||
const source = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
|
||||
assert.ok(source.includes("DEDUP_PROMPT_SECTION"), "forensics.ts must define DEDUP_PROMPT_SECTION");
|
||||
assert.ok(source.includes("gh issue list --repo gsd-build/gsd-2 --state closed"));
|
||||
assert.ok(source.includes("gh pr list --repo gsd-build/gsd-2 --state open"));
|
||||
assert.ok(source.includes("gh pr list --repo gsd-build/gsd-2 --state merged"));
|
||||
});
|
||||
|
||||
it("handleForensics checks forensics_dedup preference", () => {
|
||||
const source = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
|
||||
assert.ok(source.includes("forensics_dedup"),
|
||||
"handleForensics must reference forensics_dedup preference");
|
||||
assert.ok(source.includes("dedupSection"),
|
||||
"handleForensics must pass dedupSection to loadPrompt");
|
||||
});
|
||||
|
||||
it("first-time opt-in shows when preference is undefined", () => {
|
||||
const source = readFileSync(join(gsdDir, "forensics.ts"), "utf-8");
|
||||
assert.ok(source.includes("=== undefined"),
|
||||
"first-time detection must check for undefined (not false)");
|
||||
assert.ok(source.includes("Duplicate detection available") || source.includes("duplicate detection"),
|
||||
"opt-in notice must mention duplicate detection");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue