From 7bac93a06e6bf994e7407ce1c13c3e99cb97c060 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Sat, 14 Nov 2026 11:25:03 +0530 Subject: [PATCH] feat(issue): core duplicate-picker mod and packaging parity (part 1 of #2075) --- docs/setup/secure-agent-setup.md | 15 + .../magpie-issue/.claude-plugin/plugin.json | 1 + .../magpie-issue/.codex-plugin/plugin.json | 14 + plugins/magpie-issue/hooks/codex-hooks.json | 4 + .../hooks/duplicate-picker.test.ts | 894 ++++++++++++++++++ .../magpie-issue/hooks/duplicate-picker.ts | 579 ++++++++++++ plugins/magpie-issue/hooks/hooks.json | 7 + plugins/magpie-issue/hooks/types.d.ts | 45 + plugins/magpie-issue/hooks/types.ts | 67 ++ tools/dev/check-family-plugins.py | 4 + .../specs/issue-management-family.md | 11 + 11 files changed, 1641 insertions(+) create mode 100644 plugins/magpie-issue/.codex-plugin/plugin.json create mode 100644 plugins/magpie-issue/hooks/codex-hooks.json create mode 100644 plugins/magpie-issue/hooks/duplicate-picker.test.ts create mode 100644 plugins/magpie-issue/hooks/duplicate-picker.ts create mode 100644 plugins/magpie-issue/hooks/hooks.json create mode 100644 plugins/magpie-issue/hooks/types.d.ts create mode 100644 plugins/magpie-issue/hooks/types.ts diff --git a/docs/setup/secure-agent-setup.md b/docs/setup/secure-agent-setup.md index e71bb5c48..9a896505d 100644 --- a/docs/setup/secure-agent-setup.md +++ b/docs/setup/secure-agent-setup.md @@ -42,6 +42,7 @@ - [Trade-offs and runtime characteristics](#trade-offs-and-runtime-characteristics) - [Review findings curation mod (Claude Code plugin / hook)](#review-findings-curation-mod-claude-code-plugin--hook) - [Unsandboxed filesystem writes (`$.fs.write`)](#unsandboxed-filesystem-writes-fswrite) + - [Duplicate candidate picker mod (Claude Code plugin / hook)](#duplicate-candidate-picker-mod-claude-code-plugin--hook) - [Sandbox-error hint hook](#sandbox-error-hint-hook) - [Why install it](#why-install-it) - [Why install it user-scope, not project-scope](#why-install-it-user-scope-not-project-scope-1) @@ -2043,6 +2044,20 @@ The review findings mod introduces the framework's first use of `$.fs.write` in When a write fails, when findings become empty, or when a disposition re-run detects a mismatched PR number, the hook writes explicit invalidation markers to disk. Downstream CLI tools (`render`) validate the PR number and source hash directly before loading `.curated.json`, ensuring stale, orphaned, or invalidated curation files are refused or bypassed. +## Duplicate candidate picker mod (Claude Code plugin / hook) + +For Claude Code harnesses using the `magpie-issue` plugin, the duplicate candidate interactive pane mod ([`plugins/magpie-issue/hooks/duplicate-picker.ts`](../../plugins/magpie-issue/hooks/duplicate-picker.ts)) provides an interactive terminal overview of candidate duplicates during issue triage and deduplication. +The pane has no input until part 2 of #2075 wires the candidates file, and this part's capability surface is session state and UI rendering only. +Part 2 will add the `$.fs` IPC surface, which this section will then document. + +- **Threat model and untrusted content boundaries**: Inbound candidate issue titles, excerpts, and author handles are untrusted external data. + The mod strictly sanitizes text, stripping ANSI escapes, control codes, zero-width characters, and Unicode bidirectional overrides while preserving legitimate syntax like `Map`. + It renders inert text components within the UI pane, never evaluates issue content as instructions, and never executes unverified scripts. +- **State boundary and human sign-off**: The mod uses an isolated reactive session atom (`magpie-issue.duplicatePickerState`) reset on `session.start`. + Adhering to the framework requirement that every agent-authored proposal requires human sign-off, an untouched state remains in `reviewing` status and never drops or closes candidate issues. + Candidates are only marked for closure upon deliberate maintainer action via interactive buttons ("Keep Target" or "Set as Kept"). + Dismissed candidates provide a `Restore` button to undo accidental dismissals, and pressing a kept selection a second time clears the selection back to unselected. + ## Sandbox-error hint hook Companion to the *Sandbox-bypass visibility hook* above — a diff --git a/plugins/magpie-issue/.claude-plugin/plugin.json b/plugins/magpie-issue/.claude-plugin/plugin.json index 20a88b170..66b229f0d 100644 --- a/plugins/magpie-issue/.claude-plugin/plugin.json +++ b/plugins/magpie-issue/.claude-plugin/plugin.json @@ -9,5 +9,6 @@ "homepage": "https://magpie.apache.org/", "repository": "https://github.com/apache/magpie", "license": "Apache-2.0", + "types": "./hooks/types.d.ts", "skills": "./skills" } diff --git a/plugins/magpie-issue/.codex-plugin/plugin.json b/plugins/magpie-issue/.codex-plugin/plugin.json new file mode 100644 index 000000000..10e364cb3 --- /dev/null +++ b/plugins/magpie-issue/.codex-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "magpie-issue", + "description": "Apache Magpie \u2014 Issue management: triage, reproduction, fix drafting, reassessment, stale sweeps, deduplication and backlog statistics.", + "version": "0.9.0.dev202611140420", + "author": { + "name": "Apache Magpie", + "url": "https://magpie.apache.org/" + }, + "homepage": "https://magpie.apache.org/", + "repository": "https://github.com/apache/magpie", + "license": "Apache-2.0", + "skills": "./skills", + "hooks": "./hooks/codex-hooks.json" +} diff --git a/plugins/magpie-issue/hooks/codex-hooks.json b/plugins/magpie-issue/hooks/codex-hooks.json new file mode 100644 index 000000000..b0fac4109 --- /dev/null +++ b/plugins/magpie-issue/hooks/codex-hooks.json @@ -0,0 +1,4 @@ +{ + "description": "Apache Magpie \u2014 no Codex hooks. hooks.json holds Claude Code mods only.", + "hooks": {} +} diff --git a/plugins/magpie-issue/hooks/duplicate-picker.test.ts b/plugins/magpie-issue/hooks/duplicate-picker.test.ts new file mode 100644 index 000000000..1ecd6b0ba --- /dev/null +++ b/plugins/magpie-issue/hooks/duplicate-picker.test.ts @@ -0,0 +1,894 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, test } from 'claude-code/testing'; +import type { + DuplicatePickerState, +} from './types.js'; + +import { + createStateFromPayload, + deriveStatus, + formatSimilarityBadge, + getDuplicatePickerState, + parseCandidatePayload, + register, + renderDuplicatePickerPane, + sanitizeText, + serializeDecision, + setDuplicatePickerState, + truncateText, + updateDuplicatePickerState, +} from './duplicate-picker.js'; + +function must(v: T | null | undefined, what = 'value'): T { + if (v === null || v === undefined) throw new Error(`missing ${what}`); + return v; +} + +function createMockEngine(overrides: any = {}): any { + const store = new Map(); + return { + state: { + get: (atom: any) => { + return { isSet: store.has(atom.key), value: store.has(atom.key) ? store.get(atom.key) : null }; + }, + set: (atom: any, wrapped: any) => { + const prev = store.has(atom.key) ? store.get(atom.key) : null; + const nextVal = + typeof wrapped === 'function' + ? wrapped(prev) + : wrapped && typeof wrapped === 'object' && 'value' in wrapped + ? wrapped.value + : wrapped; + store.set(atom.key, nextVal); + return { isSet: true, value: nextVal }; + }, + }, + ui: { + invalidate: () => {}, + resolve: () => ({}), + }, + ...overrides, + }; +} + +describe('sanitizeText', () => { + test('preserves generic types and HTML-like syntax without tag stripping', () => { + const title = 'NullPointerException in Map> parsing'; + const clean = sanitizeText(title); + if (clean !== title) { + throw new Error(`expected generic brackets preserved, got: ${clean}`); + } + const comparison = 'check if a < b and c > d'; + if (sanitizeText(comparison) !== comparison) { + throw new Error(`comparison brackets broken: ${sanitizeText(comparison)}`); + } + }); + + test('strips ANSI escape sequences and control characters', () => { + const ansi = '\x1b[31mRed Alert\x1b[0m\x00\x1f'; + const clean = sanitizeText(ansi); + if (clean !== 'Red Alert') { + throw new Error(`failed to strip ANSI/control chars: ${JSON.stringify(clean)}`); + } + }); + + test('strips zero-width characters and bidirectional overrides', () => { + // U+200B (zero-width space), U+202E (right-to-left override), U+2066 (left-to-right isolate) + const bidiAttack = 'Normal Title \u202E\u2066reordered\u200Btext'; + const clean = sanitizeText(bidiAttack); + if (clean !== 'Normal Title reorderedtext') { + throw new Error(`failed to strip bidi overrides/zero-width: ${JSON.stringify(clean)}`); + } + }); + + test('normalizes whitespace and newlines cleanly', () => { + const multiline = 'Title\nLine2\r\nLine3\tExtra'; + const clean = sanitizeText(multiline); + if (clean !== 'Title Line2 Line3 Extra') { + throw new Error(`failed newline normalization: ${JSON.stringify(clean)}`); + } + }); + + test('handles non-string gracefully', () => { + if (sanitizeText(null) !== '' || sanitizeText(undefined) !== '' || sanitizeText(123) !== '') { + throw new Error('failed non-string handling'); + } + }); +}); + +describe('truncateText', () => { + test('truncates strings longer than maxLength with ellipsis', () => { + const res = truncateText('Hello World Long Text', 10); + if (!res.endsWith('\u2026') || res.length > 10) { + throw new Error(`truncateText failed: ${res}`); + } + }); + + test('leaves short strings untouched', () => { + const res = truncateText('Short', 10); + if (res !== 'Short') { + throw new Error(`truncateText altered short string: ${res}`); + } + }); +}); + +describe('parseCandidatePayload', () => { + test('accepts valid schema_version 1 payload', () => { + const raw = { + schema_version: 1, + target_issue: 100, + target_title: 'Original Bug with Map', + target_author: 'bob', + candidates: [ + { + issue_number: 99, + title: 'Duplicate Bug in Map', + author: 'alice', + similarity_score: 0.92, + labels: ['bug', 'triage'], + excerpt: 'Fails with 500 error', + }, + ], + }; + const parsed = must(parseCandidatePayload(raw)); + if (parsed.target_issue !== 100 || parsed.candidates.length !== 1) { + throw new Error(`unexpected parsed payload: ${JSON.stringify(parsed)}`); + } + if (parsed.candidates[0].similarity_score !== 0.92) { + throw new Error(`similarity score mismatch: ${parsed.candidates[0].similarity_score}`); + } + if (parsed.candidates[0].title !== 'Duplicate Bug in Map') { + throw new Error(`title brackets altered: ${parsed.candidates[0].title}`); + } + }); + + test('strictly validates integer issue numbers and rejects coercions', () => { + if (parseCandidatePayload({ schema_version: 1, target_issue: true, target_title: 'Bug' }) !== null) { + throw new Error('should reject boolean target_issue'); + } + if (parseCandidatePayload({ schema_version: 1, target_issue: [100], target_title: 'Bug' }) !== null) { + throw new Error('should reject array target_issue'); + } + if (parseCandidatePayload({ schema_version: 1, target_issue: 1.5, target_title: 'Bug' }) !== null) { + throw new Error('should reject float target_issue'); + } + if (parseCandidatePayload({ schema_version: 1, target_issue: -1, target_title: 'Bug' }) !== null) { + throw new Error('should reject negative issue numbers'); + } + if (parseCandidatePayload({ schema_version: 2, target_issue: 100, target_title: 'Bug' }) !== null) { + throw new Error('should reject schema_version != 1'); + } + }); + + test('filters out self-duplicate candidate (issue_number === target_issue)', () => { + const raw = { + schema_version: 1, + target_issue: 100, + target_title: 'Target Bug', + candidates: [ + { issue_number: 100, title: 'Self Duplicate Bug', author: 'attacker' }, + { issue_number: 95, title: 'Real Candidate', author: 'charlie' }, + ], + }; + const parsed = must(parseCandidatePayload(raw)); + if (parsed.candidates.length !== 1 || parsed.candidates[0].issue_number !== 95) { + throw new Error(`failed to filter out self-duplicate candidate: ${JSON.stringify(parsed.candidates)}`); + } + }); + + test('deduplicates candidate entries with identical issue_number', () => { + const raw = { + schema_version: 1, + target_issue: 100, + target_title: 'Target Bug', + candidates: [ + { issue_number: 95, title: 'Duplicate Candidate First', author: 'alice' }, + { issue_number: 95, title: 'Duplicate Candidate Second', author: 'alice' }, + ], + }; + const parsed = must(parseCandidatePayload(raw)); + if (parsed.candidates.length !== 1 || parsed.candidates[0].title !== 'Duplicate Candidate First') { + throw new Error(`failed to deduplicate candidate issue numbers: ${JSON.stringify(parsed.candidates)}`); + } + }); + + test('range-checks similarity_score to [0.0, 1.0] and drops invalid values', () => { + const raw = { + schema_version: 1, + target_issue: 100, + target_title: 'Target Bug', + candidates: [ + { issue_number: 1, title: 'C1', similarity_score: NaN }, + { issue_number: 2, title: 'C2', similarity_score: 5.0 }, + { issue_number: 3, title: 'C3', similarity_score: -0.5 }, + { issue_number: 4, title: 'C4', similarity_score: 0.85 }, + ], + }; + const parsed = must(parseCandidatePayload(raw)); + if (parsed.candidates[0].similarity_score !== undefined) { + throw new Error('NaN similarity_score should be dropped'); + } + if (parsed.candidates[1].similarity_score !== undefined) { + throw new Error('>1 similarity_score should be dropped'); + } + if (parsed.candidates[2].similarity_score !== undefined) { + throw new Error('<0 similarity_score should be dropped'); + } + if (parsed.candidates[3].similarity_score !== 0.85) { + throw new Error(`valid score 0.85 should be retained, got ${parsed.candidates[3].similarity_score}`); + } + }); + + test('handles non-string author safely', () => { + const raw = { + schema_version: 1, + target_issue: 100, + target_title: 'Bug', + candidates: [ + { issue_number: 1, title: 'C1', author: {} }, + { issue_number: 2, title: 'C2', author: ' ' }, + ], + }; + const parsed = must(parseCandidatePayload(raw)); + if (parsed.candidates[0].author !== 'unknown' || parsed.candidates[1].author !== 'unknown') { + throw new Error(`failed safe author fallback: ${JSON.stringify(parsed.candidates)}`); + } + }); + + test('truncates created_at to 25 chars and bounds labels to max 4 items and 20 chars each', () => { + const raw = { + schema_version: 1, + target_issue: 100, + target_title: 'Bug', + candidates: [ + { + issue_number: 1, + title: 'C1', + created_at: '2026-11-14T06:29:33.123456789Z-EXTRA-PADDING', + labels: [ + 'label-one-very-long-name-exceeding-twenty-characters', + 'label-two', + 'label-three', + 'label-four', + 'label-five-should-be-dropped', + ], + }, + ], + }; + const parsed = must(parseCandidatePayload(raw)); + const c = parsed.candidates[0]; + if (!c.created_at || c.created_at.length > 25) { + throw new Error(`created_at should be <= 25 chars, got length ${c.created_at?.length}: ${c.created_at}`); + } + if (!c.labels || c.labels.length > 4) { + throw new Error(`labels should be at most 4 items, got ${c.labels?.length}`); + } + if (c.labels[0].length > 20) { + throw new Error(`label should be <= 20 chars, got length ${c.labels[0].length}: ${c.labels[0]}`); + } + if (c.labels.includes('label-five-should-be-dropped')) { + throw new Error('5th label should have been dropped'); + } + }); +}); + +describe('createStateFromPayload', () => { + test('creates fresh DuplicatePickerState with empty selections', () => { + const payload = { + schema_version: 1 as const, + target_issue: 200, + target_title: 'Target Issue Title', + target_author: 'reporter', + candidates: [ + { issue_number: 199, title: 'Candidate 199', author: 'dev' }, + ], + }; + const state = createStateFromPayload(payload); + if (state.targetIssue !== 200 || state.targetTitle !== 'Target Issue Title') { + throw new Error(`target mismatch: ${JSON.stringify(state)}`); + } + if (deriveStatus(state) !== 'reviewing') { + throw new Error(`expected reviewing status from deriveStatus, got ${deriveStatus(state)}`); + } + if (state.selectedKeptIssue !== null) { + throw new Error(`expected selectedKeptIssue null, got ${state.selectedKeptIssue}`); + } + if (state.dismissedIssues.length !== 0) { + throw new Error(`expected empty dismissedIssues, got ${JSON.stringify(state.dismissedIssues)}`); + } + }); +}); + +describe('deriveStatus', () => { + test('returns reviewing when candidates list is empty regardless of selection', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [], + selectedKeptIssue: 100, + dismissedIssues: [], + }; + if (deriveStatus(state) !== 'reviewing') { + throw new Error(`expected reviewing for empty candidates, got ${deriveStatus(state)}`); + } + }); + + test('returns reviewing when no selection has been made and candidates are not all dismissed', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [ + { issue_number: 90, title: 'C1', author: 'a' }, + { issue_number: 91, title: 'C2', author: 'b' }, + ], + selectedKeptIssue: null, + dismissedIssues: [90], + }; + if (deriveStatus(state) !== 'reviewing') { + throw new Error(`expected reviewing, got ${deriveStatus(state)}`); + } + }); + + test('returns curated when target or candidate is selected and un-dismissed candidates remain', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [ + { issue_number: 90, title: 'C1', author: 'a' }, + { issue_number: 91, title: 'C2', author: 'b' }, + ], + selectedKeptIssue: 100, + dismissedIssues: [90], + }; + if (deriveStatus(state) !== 'curated') { + throw new Error(`expected curated, got ${deriveStatus(state)}`); + } + }); + + test('returns dismissed when all candidates are dismissed even if target was kept', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [ + { issue_number: 90, title: 'C1', author: 'a' }, + ], + selectedKeptIssue: 100, // Target was kept, but the only candidate was dismissed! + dismissedIssues: [90], + }; + if (deriveStatus(state) !== 'dismissed') { + throw new Error(`expected dismissed when all candidates dismissed, got ${deriveStatus(state)}`); + } + }); +}); + +describe('serializeDecision', () => { + test('untouched reviewing state serializes as reviewing with zero dropped issues', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [ + { issue_number: 90, title: 'Candidate 1', author: 'a' }, + { issue_number: 91, title: 'Candidate 2', author: 'b' }, + ], + selectedKeptIssue: null, + dismissedIssues: [], + }; + const dec = serializeDecision(state, '2026-11-14T10:00:00Z'); + if (dec.status !== 'reviewing') { + throw new Error(`expected reviewing status for untouched state, got ${dec.status}`); + } + if (dec.dropped_issues.length !== 0) { + throw new Error(`expected dropped_issues empty for untouched state, got ${JSON.stringify(dec.dropped_issues)}`); + } + }); + + test('serializes curated decision when target issue is explicitly kept', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [ + { issue_number: 90, title: 'Candidate 1', author: 'a' }, + { issue_number: 91, title: 'Candidate 2', author: 'b' }, + ], + selectedKeptIssue: 100, + dismissedIssues: [91], + }; + const dec = serializeDecision(state, '2026-11-14T10:00:00Z'); + if (dec.kept_issue !== 100) { + throw new Error(`expected kept_issue 100, got ${dec.kept_issue}`); + } + if (dec.dropped_issues.length !== 1 || dec.dropped_issues[0] !== 90) { + throw new Error(`expected dropped_issues [90], got ${JSON.stringify(dec.dropped_issues)}`); + } + if (dec.dismissed_issues.length !== 1 || dec.dismissed_issues[0] !== 91) { + throw new Error(`expected dismissed_issues [91], got ${JSON.stringify(dec.dismissed_issues)}`); + } + if (dec.status !== 'curated') { + throw new Error(`expected status curated, got ${dec.status}`); + } + }); + + test('serializes custom candidate as kept issue and drops target', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [{ issue_number: 90, title: 'Candidate 1', author: 'a' }], + selectedKeptIssue: 90, + dismissedIssues: [], + }; + const dec = serializeDecision(state, '2026-11-14T10:00:00Z'); + if (dec.kept_issue !== 90) { + throw new Error(`expected kept_issue 90, got ${dec.kept_issue}`); + } + if (!dec.dropped_issues.includes(100) || dec.dropped_issues.includes(90)) { + throw new Error(`expected dropped_issues to contain target 100 and not 90, got ${JSON.stringify(dec.dropped_issues)}`); + } + if (dec.status !== 'curated') { + throw new Error(`expected status curated, got ${dec.status}`); + } + }); + + test('serializes dismissed status with zero dropped issues when all candidates dismissed', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [ + { issue_number: 90, title: 'Candidate 1', author: 'a' }, + ], + selectedKeptIssue: 100, + dismissedIssues: [90], + }; + const dec = serializeDecision(state, '2026-11-14T10:00:00Z'); + if (dec.status !== 'dismissed') { + throw new Error(`expected dismissed status, got ${dec.status}`); + } + if (dec.dropped_issues.length !== 0) { + throw new Error(`expected no dropped issues on dismissed, got ${JSON.stringify(dec.dropped_issues)}`); + } + }); + + test('does not serialize dismissed if dismissedIssues has extraneous IDs not matching candidates', () => { + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target', + candidates: [ + { issue_number: 90, title: 'Candidate 1', author: 'a' }, + ], + selectedKeptIssue: null, + dismissedIssues: [999], + }; + const dec = serializeDecision(state, '2026-11-14T10:00:00Z'); + if (dec.status !== 'reviewing') { + throw new Error(`expected reviewing status when candidate 90 not dismissed, got ${dec.status}`); + } + }); +}); + +describe('formatSimilarityBadge', () => { + test('categorizes high, med, low scores correctly', () => { + const high = formatSimilarityBadge(0.9); + if (!high.label.includes('HIGH') || high.color !== 'success') { + throw new Error(`expected HIGH badge: ${JSON.stringify(high)}`); + } + const med = formatSimilarityBadge(0.7); + if (!med.label.includes('MED') || med.color !== 'warning') { + throw new Error(`expected MED badge: ${JSON.stringify(med)}`); + } + const low = formatSimilarityBadge(0.4); + if (!low.label.includes('LOW')) { + throw new Error(`expected LOW badge: ${JSON.stringify(low)}`); + } + const none = formatSimilarityBadge(undefined); + if (none.label !== 'MATCH' || none.color !== 'dim') { + throw new Error(`expected default match badge: ${JSON.stringify(none)}`); + } + }); +}); + +describe('renderDuplicatePickerPane', () => { + test('returns null when state is absent or has zero candidates', () => { + const mock$ = { ui: { resolve: () => ({ Box: () => {}, Text: () => {} }) } }; + if (renderDuplicatePickerPane(mock$, {}, null) !== null) { + throw new Error('should return null for null state'); + } + const emptyState: DuplicatePickerState = { + targetIssue: 1, + targetTitle: 'Empty', + candidates: [], + selectedKeptIssue: null, + dismissedIssues: [], + }; + if (renderDuplicatePickerPane(mock$, {}, emptyState) !== null) { + throw new Error('should return null for empty candidates'); + } + }); + + test('constructs structured Box tree with single-argument props and children', () => { + let boxCalls = 0; + let textCalls = 0; + let buttonCalls = 0; + + const mockBox = (props: any) => { + boxCalls += 1; + return { type: 'Box', ...props }; + }; + + const mockText = (props: any) => { + textCalls += 1; + return { type: 'Text', ...props }; + }; + + const mockButton = (props: any) => { + buttonCalls += 1; + return { type: 'Button', ...props }; + }; + + const mock$ = { + ui: { + resolve: () => ({ + Box: mockBox, + Text: mockText, + Button: mockButton, + }), + }, + }; + + const state: DuplicatePickerState = { + targetIssue: 50, + targetTitle: 'Target Issue Title with Map', + targetAuthor: 'alice', + candidates: [ + { + issue_number: 48, + title: 'Candidate Duplicate Title', + author: 'bob', + created_at: '2026-11-14T06:29:33Z', + labels: ['area:issue', 'bug'], + similarity_score: 0.88, + excerpt: 'Matches stack trace', + }, + ], + selectedKeptIssue: null, + dismissedIssues: [], + }; + + const pane = renderDuplicatePickerPane(mock$, { component: 'Pane' }, state); + if (!pane || pane.type !== 'Box') { + throw new Error(`expected rendered Box root, got ${JSON.stringify(pane)}`); + } + if (boxCalls === 0 || textCalls === 0 || buttonCalls === 0) { + throw new Error(`expected all components invoked, got box=${boxCalls}, text=${textCalls}, button=${buttonCalls}`); + } + if (!Array.isArray(pane.children)) { + throw new Error('pane should contain children array in props'); + } + }); + + test('button onPress handlers trigger atomic state updates, selection toggle, and restore', async () => { + const buttons: Array<{ key: string; label: string; onPress: () => Promise }> = []; + + const mockBox = (props: any) => ({ type: 'Box', ...props }); + const mockText = (props: any) => ({ type: 'Text', ...props }); + const mockButton = (props: any) => { + buttons.push(props); + return { type: 'Button', ...props }; + }; + + const test$ = createMockEngine({ + ui: { + resolve: () => ({ + Box: mockBox, + Text: mockText, + Button: mockButton, + }), + invalidate: () => {}, + }, + }); + + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target Title', + candidates: [ + { issue_number: 99, title: 'Candidate 99', author: 'alice' }, + ], + selectedKeptIssue: null, + dismissedIssues: [], + }; + + await setDuplicatePickerState(test$, state); + renderDuplicatePickerPane(test$, { component: 'Pane' }, state); + + // 1. Keep Target button onPress -> selects target 100 + const targetBtn = buttons.find((b) => b.key === 'target-kept-100'); + if (!targetBtn) throw new Error('Keep Target button missing'); + await targetBtn.onPress(); + let current = await getDuplicatePickerState(test$); + if (!current || current.selectedKeptIssue !== 100 || deriveStatus(current) !== 'curated') { + throw new Error(`Target button onPress failed: ${JSON.stringify(current)}`); + } + + // 2. Toggle Target button onPress -> toggles selection back to null + await targetBtn.onPress(); + current = await getDuplicatePickerState(test$); + if (!current || current.selectedKeptIssue !== null || deriveStatus(current) !== 'reviewing') { + throw new Error(`Target button toggle unselect failed: ${JSON.stringify(current)}`); + } + + // 3. Set as Kept button onPress on candidate 99 -> selects candidate 99 + const keptBtn = buttons.find((b) => b.key === 'kept-99'); + if (!keptBtn) throw new Error('Set as Kept button missing'); + await keptBtn.onPress(); + current = await getDuplicatePickerState(test$); + if (!current || current.selectedKeptIssue !== 99 || deriveStatus(current) !== 'curated') { + throw new Error(`Candidate kept button onPress failed: ${JSON.stringify(current)}`); + } + + // 4. Toggle Candidate button onPress -> toggles selection back to null + await keptBtn.onPress(); + current = await getDuplicatePickerState(test$); + if (!current || current.selectedKeptIssue !== null) { + throw new Error(`Candidate kept button toggle unselect failed: ${JSON.stringify(current)}`); + } + + // 5. Dismiss button onPress on candidate 99 + const dismissBtn = buttons.find((b) => b.key === 'dismiss-99'); + if (!dismissBtn) throw new Error('Dismiss button missing'); + await dismissBtn.onPress(); + current = await getDuplicatePickerState(test$); + if (!current || !current.dismissedIssues.includes(99) || deriveStatus(current) !== 'dismissed') { + throw new Error(`Dismiss button onPress failed: ${JSON.stringify(current)}`); + } + + // 6. Render again with candidate 99 dismissed -> should render a Restore button! + buttons.length = 0; + renderDuplicatePickerPane(test$, { component: 'Pane' }, current); + const restoreBtn = buttons.find((b) => b.key === 'restore-99'); + if (!restoreBtn) throw new Error('Restore button missing for dismissed candidate 99'); + await restoreBtn.onPress(); + current = await getDuplicatePickerState(test$); + if (!current || current.dismissedIssues.includes(99) || deriveStatus(current) !== 'reviewing') { + throw new Error(`Restore button onPress failed: ${JSON.stringify(current)}`); + } + }); + + test('rapid sequential button presses in same render persist both updates without race conditions', async () => { + const buttons: Array<{ key: string; label: string; onPress: () => Promise }> = []; + + const mockBox = (props: any) => ({ type: 'Box', ...props }); + const mockText = (props: any) => ({ type: 'Text', ...props }); + const mockButton = (props: any) => { + buttons.push(props); + return { type: 'Button', ...props }; + }; + + const test$ = createMockEngine({ + ui: { + resolve: () => ({ Box: mockBox, Text: mockText, Button: mockButton }), + invalidate: () => {}, + }, + }); + + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target Title', + candidates: [ + { issue_number: 98, title: 'Candidate 98', author: 'alice' }, + { issue_number: 99, title: 'Candidate 99', author: 'bob' }, + ], + selectedKeptIssue: 100, + dismissedIssues: [], + }; + + await setDuplicatePickerState(test$, state); + renderDuplicatePickerPane(test$, { component: 'Pane' }, state); + + const dismiss98 = buttons.find((b) => b.key === 'dismiss-98'); + const dismiss99 = buttons.find((b) => b.key === 'dismiss-99'); + if (!dismiss98 || !dismiss99) throw new Error('Dismiss buttons missing'); + + await dismiss98.onPress(); + await dismiss99.onPress(); + + const current = await getDuplicatePickerState(test$); + if (!current) throw new Error('state null after rapid presses'); + if (!current.dismissedIssues.includes(98) || !current.dismissedIssues.includes(99)) { + throw new Error(`expected both 98 and 99 dismissed, got: ${JSON.stringify(current.dismissedIssues)}`); + } + if (deriveStatus(current) !== 'dismissed') { + throw new Error(`expected dismissed status when all candidates dismissed, got: ${deriveStatus(current)}`); + } + }); +}); + +describe('register', () => { + test('registers session.start and ui.render (Pane) lifecycle hooks cleanly', () => { + const registered: Array<{ event: string; opts?: any; handler: Function }> = []; + const fakeOn = (event: string, optsOrHandler: any, handler?: Function) => { + if (typeof optsOrHandler === 'function') { + registered.push({ event, handler: optsOrHandler }); + } else { + registered.push({ event, opts: optsOrHandler, handler: handler! }); + } + }; + + register(fakeOn); + if (registered.length < 2) { + throw new Error(`expected at least 2 hooks, got ${registered.length}`); + } + const sessionHook = registered.find((r) => r.event === 'session.start'); + if (!sessionHook) { + throw new Error('missing session.start hook'); + } + const renderHook = registered.find((r) => r.event === 'ui.render'); + if (!renderHook || renderHook.opts?.component !== 'Pane') { + throw new Error('ui.render hook missing or missing { component: "Pane" } matcher'); + } + }); + + test('ui.render lifecycle handler returns next(e) on empty atom state', async () => { + const registered: Array<{ event: string; opts?: any; handler: Function }> = []; + register((event: string, optsOrHandler: any, handler?: Function) => { + registered.push({ event, handler: typeof optsOrHandler === 'function' ? optsOrHandler : handler! }); + }); + const renderHook = registered.find((r) => r.event === 'ui.render')!; + + const test$ = createMockEngine(); + let nextCalled = false; + const testE = { component: 'Pane', id: 'main' }; + const nextSpy = (e: any) => { + nextCalled = true; + return { passedThrough: true, e }; + }; + + const result = await renderHook.handler(test$, testE, nextSpy); + if (!nextCalled || !result || !result.passedThrough) { + throw new Error('ui.render failed to call next(e) on empty state'); + } + }); + + test('ui.render lifecycle handler returns rendered Pane on populated atom state', async () => { + const registered: Array<{ event: string; opts?: any; handler: Function }> = []; + register((event: string, optsOrHandler: any, handler?: Function) => { + registered.push({ event, handler: typeof optsOrHandler === 'function' ? optsOrHandler : handler! }); + }); + const renderHook = registered.find((r) => r.event === 'ui.render')!; + + const mockBox = (props: any) => ({ type: 'Box', ...props }); + const mockText = (props: any) => ({ type: 'Text', ...props }); + const mockButton = (props: any) => ({ type: 'Button', ...props }); + + const test$ = createMockEngine({ + ui: { + resolve: () => ({ Box: mockBox, Text: mockText, Button: mockButton }), + invalidate: () => {}, + }, + }); + + const state: DuplicatePickerState = { + targetIssue: 100, + targetTitle: 'Target Title', + candidates: [{ issue_number: 99, title: 'C99', author: 'alice' }], + selectedKeptIssue: null, + dismissedIssues: [], + }; + await setDuplicatePickerState(test$, state); + + let nextCalled = false; + const nextSpy = () => { + nextCalled = true; + return { passedThrough: true }; + }; + + const result = await renderHook.handler(test$, { component: 'Pane' }, nextSpy); + if (nextCalled) { + throw new Error('ui.render should not call next(e) when state is populated'); + } + if (!result || result.type !== 'Box') { + throw new Error(`expected Box pane returned, got: ${JSON.stringify(result)}`); + } + }); + + test('ui.render lifecycle handler fails open and calls next(e) if reading state throws', async () => { + const registered: Array<{ event: string; opts?: any; handler: Function }> = []; + register((event: string, optsOrHandler: any, handler?: Function) => { + registered.push({ event, handler: typeof optsOrHandler === 'function' ? optsOrHandler : handler! }); + }); + const renderHook = registered.find((r) => r.event === 'ui.render')!; + + const broken$ = { + state: { + get: () => { + throw new Error('disk read fault'); + }, + }, + }; + + let nextCalled = false; + const nextSpy = () => { + nextCalled = true; + return { recovered: true }; + }; + + const result = await renderHook.handler(broken$, { component: 'Pane' }, nextSpy); + if (!nextCalled || !result || !result.recovered) { + throw new Error('ui.render failed open error handling'); + } + }); + + test('session.start lifecycle handler clears populated atom state and calls next(e)', async () => { + const registered: Array<{ event: string; opts?: any; handler: Function }> = []; + register((event: string, optsOrHandler: any, handler?: Function) => { + registered.push({ event, handler: typeof optsOrHandler === 'function' ? optsOrHandler : handler! }); + }); + const sessionHook = registered.find((r) => r.event === 'session.start')!; + + const test$ = createMockEngine(); + await setDuplicatePickerState(test$, { + targetIssue: 50, + targetTitle: 'Populated', + candidates: [{ issue_number: 49, title: 'C', author: 'a' }], + selectedKeptIssue: 50, + dismissedIssues: [], + }); + + let nextCalled = false; + const nextSpy = (e: any) => { + nextCalled = true; + return { started: true, e }; + }; + + const result = await sessionHook.handler(test$, { session: '123' }, nextSpy); + if (!nextCalled || !result || !result.started) { + throw new Error('session.start failed to call next(e)'); + } + const state = await getDuplicatePickerState(test$); + if (state !== null) { + throw new Error(`expected session.start to reset atom state to null, got: ${JSON.stringify(state)}`); + } + }); + + test('session.start lifecycle handler fails open and calls next(e) if resetting state throws', async () => { + const registered: Array<{ event: string; opts?: any; handler: Function }> = []; + register((event: string, optsOrHandler: any, handler?: Function) => { + registered.push({ event, handler: typeof optsOrHandler === 'function' ? optsOrHandler : handler! }); + }); + const sessionHook = registered.find((r) => r.event === 'session.start')!; + + const broken$ = { + state: { + set: () => { + throw new Error('store write fault'); + }, + }, + }; + + let nextCalled = false; + const nextSpy = () => { + nextCalled = true; + return { recovered: true }; + }; + + const result = await sessionHook.handler(broken$, {}, nextSpy); + if (!nextCalled || !result || !result.recovered) { + throw new Error('session.start failed open error handling'); + } + }); +}); diff --git a/plugins/magpie-issue/hooks/duplicate-picker.ts b/plugins/magpie-issue/hooks/duplicate-picker.ts new file mode 100644 index 000000000..32177836e --- /dev/null +++ b/plugins/magpie-issue/hooks/duplicate-picker.ts @@ -0,0 +1,579 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { atom, read, update } from 'claude-code'; +import type { + CandidateIssue, + DuplicateCandidatePayload, + DuplicatePickerDecision, + DuplicatePickerState, +} from './types.js'; + +export const duplicatePickerState = atom( + { plugin: 'magpie-issue', key: 'duplicatePickerState' }, + null as DuplicatePickerState | null +); + +/** + * Retrieves the current duplicatePickerState from the session store. + */ +export async function getDuplicatePickerState($: any): Promise { + try { + return (await read($, duplicatePickerState)) ?? null; + } catch { + return null; + } +} + +/** + * Sets the duplicatePickerState in the session store and invalidates the UI. + */ +export async function setDuplicatePickerState( + $: any, + state: DuplicatePickerState | null +): Promise { + try { + await update($, duplicatePickerState, () => state); + try { + $.ui.invalidate('ui.render'); + } catch { + // Fail-open in non-interactive/headless contexts + } + } catch { + // Fail-open + } +} + +/** + * Atomically updates the duplicatePickerState in the session store and invalidates the UI. + */ +export async function updateDuplicatePickerState( + $: any, + updater: (prev: DuplicatePickerState | null) => DuplicatePickerState | null +): Promise { + try { + await update($, duplicatePickerState, (prev: any) => updater(prev ?? null)); + try { + $.ui.invalidate('ui.render'); + } catch { + // Fail-open in non-interactive/headless contexts + } + } catch { + // Fail-open + } +} + +/** + * Strips ANSI CSI and OSC sequences, zero-width characters, bidirectional overrides, + * and C0/C1 control codes from untrusted external issue titles or excerpts. + * Note: Legitimate syntax such as generic types (e.g. Map) is preserved. + */ +export function sanitizeText(raw: unknown): string { + if (typeof raw !== 'string') { + return ''; + } + return raw + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[@-Z\\-_]/g, '') + // Zero-width (U+200B-U+200D, U+FEFF) and Bidirectional overrides (U+202A-U+202E, U+2066-U+2069) + .replace(/[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]/g, '') + .replace(/[\r\n\t]+/g, ' ') + .replace(/[\x00-\x1f\x7f-\x9f]/g, '') + .trim(); +} + +/** + * Truncates text cleanly with an ellipsis. + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}\u2026`; +} + +/** + * Parses and validates an untrusted JSON candidate payload. + */ +export function parseCandidatePayload(raw: unknown): DuplicateCandidatePayload | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return null; + } + const obj = raw as Record; + if (obj.schema_version !== 1) { + return null; + } + if ( + typeof obj.target_issue !== 'number' || + !Number.isInteger(obj.target_issue) || + obj.target_issue <= 0 + ) { + return null; + } + const targetIssue = obj.target_issue; + const targetTitle = sanitizeText(obj.target_title); + if (!targetTitle) { + return null; + } + const targetAuthor = sanitizeText(obj.target_author) || undefined; + const candidates: CandidateIssue[] = []; + + if (Array.isArray(obj.candidates)) { + for (const item of obj.candidates) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + continue; + } + const c = item as Record; + if ( + typeof c.issue_number !== 'number' || + !Number.isInteger(c.issue_number) || + c.issue_number <= 0 + ) { + continue; + } + const issueNum = c.issue_number; + // Do not allow an issue to be a candidate duplicate of itself or duplicate existing candidates + if (issueNum === targetIssue || candidates.some((existing) => existing.issue_number === issueNum)) { + continue; + } + const title = sanitizeText(c.title); + if (!title) { + continue; + } + const author = sanitizeText(c.author) || 'unknown'; + let score: number | undefined; + if ( + typeof c.similarity_score === 'number' && + !Number.isNaN(c.similarity_score) && + c.similarity_score >= 0 && + c.similarity_score <= 1 + ) { + score = c.similarity_score; + } + const createdAt = c.created_at + ? truncateText(sanitizeText(c.created_at), 25) + : undefined; + const excerpt = c.excerpt ? sanitizeText(c.excerpt) : undefined; + const labels = Array.isArray(c.labels) + ? c.labels + .map((l) => truncateText(sanitizeText(l), 20)) + .filter(Boolean) + .slice(0, 4) + : undefined; + + candidates.push({ + issue_number: issueNum, + title, + author, + created_at: createdAt, + similarity_score: score, + labels: labels && labels.length > 0 ? labels : undefined, + excerpt, + }); + } + } + + return { + schema_version: 1, + target_issue: targetIssue, + target_title: targetTitle, + target_author: targetAuthor, + candidates, + }; +} + +/** + * Creates initial DuplicatePickerState from a validated payload. + */ +export function createStateFromPayload(payload: DuplicateCandidatePayload): DuplicatePickerState { + return { + targetIssue: payload.target_issue, + targetTitle: payload.target_title, + targetAuthor: payload.target_author, + candidates: payload.candidates, + selectedKeptIssue: null, + dismissedIssues: [], + }; +} + +/** + * Derives the decision status dynamically from the current maintainer selection. + * Single source of truth shared by the serializer, footer, and tests. + */ +export function deriveStatus( + state: DuplicatePickerState +): 'reviewing' | 'curated' | 'dismissed' { + if (!state.candidates || state.candidates.length === 0) { + return 'reviewing'; + } + + const allDismissed = state.candidates.every((c) => + state.dismissedIssues.includes(c.issue_number) + ); + if (allDismissed) { + return 'dismissed'; + } + + if (state.selectedKeptIssue !== null) { + return 'curated'; + } + + return 'reviewing'; +} + +/** + * Serializes the current maintainer selection into a standard DuplicatePickerDecision. + */ +export function serializeDecision( + state: DuplicatePickerState, + nowIso = new Date().toISOString() +): DuplicatePickerDecision { + const status = deriveStatus(state); + const kept = state.selectedKeptIssue ?? state.targetIssue; + const pool = [state.targetIssue, ...state.candidates.map((c) => c.issue_number)]; + // Only drop candidates if the maintainer actually made a curated selection! + const dropped = + status === 'curated' + ? Array.from(new Set(pool)).filter( + (num) => num !== kept && !state.dismissedIssues.includes(num) + ) + : []; + + return { + schema_version: 1, + target_issue: state.targetIssue, + status, + kept_issue: kept, + dropped_issues: dropped, + dismissed_issues: [...state.dismissedIssues], + updated_at: nowIso, + }; +} + +/** + * Formats a similarity score into a human-readable badge. + */ +export function formatSimilarityBadge(score?: number): { + label: string; + color: string; +} { + if (score === undefined || score === null) { + return { label: 'MATCH', color: 'dim' }; + } + const pct = Math.round(score * 100); + if (pct >= 85) { + return { label: `${pct}% HIGH`, color: 'success' }; + } + if (pct >= 65) { + return { label: `${pct}% MED`, color: 'warning' }; + } + return { label: `${pct}% LOW`, color: 'dim' }; +} + +/** + * Renders the duplicate picker pane into Claude Code UI. + */ +export function renderDuplicatePickerPane( + $: any, + e: any, + state: DuplicatePickerState | null +): any { + if (!state || !state.candidates || state.candidates.length === 0) { + return null; + } + + let Box: any; + let Text: any; + let Button: any; + + try { + const resolved = $.ui.resolve(e); + Box = resolved.Box; + Text = resolved.Text; + Button = typeof resolved.Button === 'function' ? resolved.Button : undefined; + } catch { + return null; + } + + if (!Box || !Text) { + return null; + } + + const items: any[] = []; + const isTargetKept = state.selectedKeptIssue === state.targetIssue; + + // Header: Target issue with explicit canonical action + const headerChildren: any[] = [ + Text({ + children: [`[Deduplication Target: #${state.targetIssue}]`], + bold: true, + color: 'info', + }), + Text({ + children: [truncateText(state.targetTitle, 60)], + color: 'dim', + }), + ]; + + if (Button) { + headerChildren.push( + Box({ + flexDirection: 'row', + marginTop: 1, + children: [ + Button({ + key: `target-kept-${state.targetIssue}`, + label: isTargetKept + ? `Kept (Target #${state.targetIssue})` + : `Keep Target #${state.targetIssue}`, + onPress: async () => { + await updateDuplicatePickerState($, (prev) => { + if (!prev) return null; + return { + ...prev, + selectedKeptIssue: + prev.selectedKeptIssue === prev.targetIssue ? null : prev.targetIssue, + }; + }); + }, + }), + ], + }) + ); + } + + items.push( + Box({ + flexDirection: 'column', + marginBottom: 1, + padding: 1, + borderStyle: 'single', + borderColor: isTargetKept ? 'green' : 'cyan', + children: headerChildren, + }) + ); + + // Candidate list + for (const c of state.candidates) { + const isDismissed = state.dismissedIssues.includes(c.issue_number); + const isKept = state.selectedKeptIssue === c.issue_number; + const badge = formatSimilarityBadge(c.similarity_score); + const authorTimestamp = c.created_at + ? `(@${c.author} • ${c.created_at})` + : `(@${c.author})`; + + const childElements: any[] = [ + Box({ + flexDirection: 'row', + gap: 1, + children: [ + Text({ + children: [`[${badge.label}]`], + bold: true, + color: badge.color, + }), + Text({ + children: [`#${c.issue_number}: ${truncateText(c.title, 40)}`], + bold: true, + strikethrough: isDismissed, + }), + Text({ + children: [authorTimestamp], + color: 'dim', + }), + ], + }), + ]; + + if (c.labels && c.labels.length > 0) { + childElements.push( + Text({ + children: [` Labels: ${c.labels.join(', ')}`], + color: 'dim', + }) + ); + } + + if (c.excerpt) { + childElements.push( + Text({ + children: [` ${truncateText(c.excerpt, 60)}`], + color: 'dim', + }) + ); + } + + // Interactive buttons if Button component exists + if (Button) { + const actions: any[] = []; + if (isDismissed) { + actions.push( + Button({ + key: `restore-${c.issue_number}`, + label: 'Restore', + onPress: async () => { + await updateDuplicatePickerState($, (prev) => { + if (!prev) return null; + return { + ...prev, + dismissedIssues: prev.dismissedIssues.filter((id) => id !== c.issue_number), + }; + }); + }, + }) + ); + } else { + actions.push( + Button({ + key: `kept-${c.issue_number}`, + label: isKept ? 'Kept (Canonical)' : 'Set as Kept', + onPress: async () => { + await updateDuplicatePickerState($, (prev) => { + if (!prev) return null; + return { + ...prev, + selectedKeptIssue: + prev.selectedKeptIssue === c.issue_number ? null : c.issue_number, + }; + }); + }, + }), + Button({ + key: `dismiss-${c.issue_number}`, + label: 'Dismiss', + onPress: async () => { + await updateDuplicatePickerState($, (prev) => { + if (!prev) return null; + const nextDismissed = prev.dismissedIssues.includes(c.issue_number) + ? prev.dismissedIssues + : [...prev.dismissedIssues, c.issue_number]; + return { + ...prev, + dismissedIssues: nextDismissed, + selectedKeptIssue: + prev.selectedKeptIssue === c.issue_number ? null : prev.selectedKeptIssue, + }; + }); + }, + }) + ); + } + + childElements.push( + Box({ + flexDirection: 'row', + gap: 1, + marginTop: 1, + children: actions, + }) + ); + } + + items.push( + Box({ + flexDirection: 'column', + padding: 1, + borderStyle: 'single', + borderColor: isKept ? 'green' : isDismissed ? 'gray' : 'cyan', + marginBottom: 1, + children: childElements, + }) + ); + } + + // Summary / Status footer + const status = deriveStatus(state); + const footerChildren: any[] = [ + Text({ children: ['Decision:'], bold: true }), + ]; + + if (status === 'curated') { + const keptIssue = state.selectedKeptIssue ?? state.targetIssue; + const pool = [state.targetIssue, ...state.candidates.map((c) => c.issue_number)]; + const dropped = Array.from(new Set(pool)).filter( + (num) => num !== keptIssue && !state.dismissedIssues.includes(num) + ); + footerChildren.push( + Text({ children: [`Keep #${keptIssue} (Canonical)`], color: 'success' }), + Text({ + children: [ + `(Drop ${dropped.length} ${dropped.length === 1 ? 'duplicate' : 'duplicates'}; ${state.dismissedIssues.length} dismissed)`, + ], + color: 'dim', + }) + ); + } else if (status === 'dismissed') { + footerChildren.push( + Text({ children: ['All Candidates Dismissed (No duplicates)'], color: 'warning' }) + ); + } else { + footerChildren.push( + Text({ children: ['Reviewing (No selection yet - nothing will be dropped)'], color: 'dim' }) + ); + } + + items.push( + Box({ + flexDirection: 'row', + gap: 1, + marginTop: 1, + children: footerChildren, + }) + ); + + return Box({ + flexDirection: 'column', + padding: 1, + children: items, + }); +} + +/** + * Registers the magpie-issue duplicate-picker mod hooks. + */ +export function register(hooks: any): void { + hooks('session.start', async ($: any, e: any, next: any) => { + try { + await setDuplicatePickerState($, null); + } catch { + // Fail-open + } + if (typeof next === 'function') { + return next(e); + } + }); + + hooks('ui.render', { component: 'Pane' }, async ($: any, e: any, next: any) => { + try { + const state = await getDuplicatePickerState($); + const pane = renderDuplicatePickerPane($, e, state); + if (pane) { + return pane; + } + } catch { + // Fail-open + } + if (typeof next === 'function') { + return next(e); + } + }); +} + +export default register; diff --git a/plugins/magpie-issue/hooks/hooks.json b/plugins/magpie-issue/hooks/hooks.json new file mode 100644 index 000000000..8b3d79d97 --- /dev/null +++ b/plugins/magpie-issue/hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://code.claude.com/schemas/hooks.json", + "description": "Apache Magpie — Claude Code mod for interactive duplicate candidate picker in issue-deduplicate.", + "modules": [ + "./duplicate-picker.ts" + ] +} diff --git a/plugins/magpie-issue/hooks/types.d.ts b/plugins/magpie-issue/hooks/types.d.ts new file mode 100644 index 000000000..2cbd762be --- /dev/null +++ b/plugins/magpie-issue/hooks/types.d.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Self-contained ambient declaration required by strict plugin validation. +// Companion TypeScript runtime definitions live in types.ts. Both type declarations +// must be kept in sync when modifying the state shape. + +declare module 'claude-code' { + interface PluginState { + 'magpie-issue': { + duplicatePickerState: { + targetIssue: number; + targetTitle: string; + targetAuthor?: string; + candidates: Array<{ + issue_number: number; + title: string; + author: string; + created_at?: string; + similarity_score?: number; + labels?: string[]; + excerpt?: string; + }>; + selectedKeptIssue: number | null; + dismissedIssues: number[]; + } | null; + }; + } +} diff --git a/plugins/magpie-issue/hooks/types.ts b/plugins/magpie-issue/hooks/types.ts new file mode 100644 index 000000000..d88a04150 --- /dev/null +++ b/plugins/magpie-issue/hooks/types.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Candidate issue surfaced during triage or deduplication. + */ +export interface CandidateIssue { + issue_number: number; + title: string; + author: string; + created_at?: string; + similarity_score?: number; + labels?: string[]; + excerpt?: string; +} + +/** + * Staged candidate payload written by the agent/skill to disk. + */ +export interface DuplicateCandidatePayload { + schema_version: 1; + target_issue: number; + target_title: string; + target_author?: string; + candidates: CandidateIssue[]; +} + +/** + * Curated decision staged by maintainer interaction in the UI pane. + */ +export interface DuplicatePickerDecision { + schema_version: 1; + target_issue: number; + status: 'reviewing' | 'curated' | 'dismissed'; + kept_issue: number; + dropped_issues: number[]; + dismissed_issues: number[]; + updated_at: string; +} + +/** + * Reactive state stored in the Claude Code atom for the duplicate-picker pane. + */ +export interface DuplicatePickerState { + targetIssue: number; + targetTitle: string; + targetAuthor?: string; + candidates: CandidateIssue[]; + selectedKeptIssue: number | null; + dismissedIssues: number[]; +} diff --git a/tools/dev/check-family-plugins.py b/tools/dev/check-family-plugins.py index a1dbd236d..bdf6be0d4 100644 --- a/tools/dev/check-family-plugins.py +++ b/tools/dev/check-family-plugins.py @@ -130,6 +130,10 @@ ] }, }, + "magpie-issue": { + "description": "Apache Magpie — no Codex hooks. hooks.json holds Claude Code mods only.", + "hooks": {}, + }, "magpie-pr-management": { "description": "Apache Magpie — no Codex hooks. hooks.json holds Claude Code mods only.", "hooks": {}, diff --git a/tools/spec-loop/specs/issue-management-family.md b/tools/spec-loop/specs/issue-management-family.md index dec8ce785..20f00cdce 100644 --- a/tools/spec-loop/specs/issue-management-family.md +++ b/tools/spec-loop/specs/issue-management-family.md @@ -128,6 +128,10 @@ unchanged. closing the duplicate after the maintainer confirms. Never closes without explicit confirmation. Ships `mode: Triage` + `capability: capability:resolve` + `experimental`. + Under Claude Code the plugin also ships a mod, + `plugins/magpie-issue/hooks/duplicate-picker.ts`, wired through + `hooks/hooks.json` with its types in `hooks/types.ts` and `types.d.ts` + (part 1 of #2075; part 2 wires the candidate-payload session reader and skill integration). - Family README: `docs/issue-management/README.md` — family overview, mode boundary with `pr-management-*` and `security-issue-*`, and a link @@ -184,6 +188,13 @@ unchanged. the same bug reported in both a JIRA project and GitHub Issues) are out of scope. +- **The duplicate candidate picker pane is additive.** Under Claude Code, a + duplicate candidate payload hydrates the mod's interactive pane above the prompt. + The mod renders the deduplication target and candidate issues, allowing maintainers + to curate matches (Keep Target, Set as Kept, Dismiss, Restore, or Toggle Kept) + before any mutation is proposed. Part 1 (#2076) provides the core UI and state + management; part 2 connects the file hydration and skill workflow. + ## Out of scope - **Auto-closing or auto-labelling without confirmation.** No skill applies