From c0fc4eddd02b019cb20337a03273ac86293d4e10 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 04:41:44 +0000 Subject: [PATCH] feat: warn before duplicate mobile issue capture (#371) --- README.md | 6 +- frontend/create-issue-sheet.js | 51 ++++++++- frontend/dashboard.css | 6 ++ frontend/dashboard.js | 91 ++++++++++++++-- frontend/index.html | 6 ++ frontend/service-worker.js | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_my_work.py | 126 ++++++++++++++++++++++ tests/test_service_worker.py | 18 +++- tests/test_today_sync.py | 2 +- 12 files changed, 293 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d2fda8e..8669ea9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ inspect/comment on assigned pull requests, merge assigned pull requests, and submit pull-request reviews. Pull-request replies and mobile My Work issue and PR comments use Gitea's issue-comment API; mobile issue capture requires issue -creation and assignment permission. The New issue sheet can optionally select an open +creation and assignment permission. Once a repository and meaningful title are selected, +the New issue sheet checks for similar open issues in that repository. Candidate links keep +the draft intact; the first create attempt pauses until the operator reviews them or explicitly +chooses **Create anyway**. This check is advisory and never blocks offline capture or capture +when search is unavailable. The sheet can also optionally select an open repository milestone and due date; the dashboard validates both and sends them with self-assignment in the single create request, so planned work appears in its release lane immediately. On a cold offline launch, **Save for filing** stores up to 20 diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index e1f55bd..64f0dc3 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -23,6 +23,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp const storageKey = 'stackchain.issue-capture.v1'; const sharedStorageKey = 'stackchain.issue-share.v1'; let pending = null; + let duplicateRequest = 0; + let duplicateState = {status: 'idle', key: '', candidates: []}; + let acknowledgedDuplicateKey = ''; const safeLabelIds = value => Array.from(new Set( (Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0) )).slice(0, 20); @@ -140,6 +143,51 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp ); } + function duplicateKey(draft) { + const repository = String(draft?.repository || '').trim(); + const title = String(draft?.title || '').replace(/\s+/g, ' ').trim(); + return { repository, title, key: repository + '\n' + title.toLowerCase() }; + } + + async function findDuplicates(draft) { + const input = duplicateKey(draft); + const request = ++duplicateRequest; + if (!input.repository || input.title.length < 4) { + duplicateState = {status: 'idle', key: input.key, candidates: []}; + return duplicateState; + } + try { + const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10'); + if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []}; + duplicateState = { + status: 'ready', + key: input.key, + partial: payload?.partial === true, + candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item => + item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository + ).slice(0, 3), + }; + return duplicateState; + } catch (error) { + if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []}; + duplicateState = {status: 'failed', key: input.key, candidates: [], error}; + return duplicateState; + } + } + + function needsDuplicateAcknowledgement(draft) { + const {key} = duplicateKey(draft); + return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key && + duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key; + } + + function acknowledgeDuplicates(draft) { + const {key} = duplicateKey(draft); + if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) { + acknowledgedDuplicateKey = key; + } + } + function submit(draft) { if (pending) return pending; const saved = saveDraft(draft); @@ -166,7 +214,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp } return { - saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit, + saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates, + needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, }; } diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 0084993..0e48ad8 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -274,6 +274,12 @@ textarea { resize: vertical; min-height: 120px; } .create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; } .create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; } .create-issue-label-option input { width:20px; height:20px; margin:0; } +.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; } +.create-issue-duplicates[hidden] { display:none; } +.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; } +.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; } +.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; } +.create-issue-duplicate-card a { color:#93c5fd; } .create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; } .shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 2b9ca83..f455245 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -1717,6 +1717,67 @@ }); } + function currentIssueCaptureDraft() { + return { + repository: qs('#create-issue-repository').value, + title: qs('#create-issue-title').value.trim(), + body: qs('#create-issue-body').value.trim(), + labelIds: selectedIssueLabelIds(), + milestoneId: Number(qs('#create-issue-milestone').value) || null, + dueDate: qs('#create-issue-due-date').value, + }; + } + + let duplicateCheckTimer = null; + function renderIssueDuplicates(state) { + if (state.status === 'stale') return; + const panel = qs('#create-issue-duplicates'); + const list = qs('#create-issue-duplicate-list'); + const status = qs('#create-issue-duplicate-status'); + const anyway = qs('#create-issue-anyway'); + list.replaceChildren(); + anyway.hidden = true; + if (state.status === 'idle') { + panel.hidden = true; + return; + } + panel.hidden = false; + if (state.status === 'failed') { + status.textContent = 'Could not check for existing issues. You can still create this issue.'; + return; + } + if (!state.candidates.length) { + status.textContent = 'No similar open issues found in this repository.'; + return; + } + status.textContent = (state.partial ? 'Search was incomplete. ' : '') + state.candidates.length + + ' similar open issue' + (state.candidates.length === 1 ? '' : 's') + + ' found. ' + (state.partial ? 'You can continue without waiting.' : 'Review before creating another.'); + state.candidates.forEach(item => { + const card = document.createElement('article'); + card.className = 'create-issue-duplicate-card'; + const key = document.createElement('span'); + key.className = 'small'; + key.textContent = item.repository + ' #' + item.number; + const link = document.createElement('a'); + link.textContent = item.title || 'Review existing issue'; + link.href = safeSearchUrl(item.url) || '#'; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.addEventListener('click', saveIssueCaptureDraft); + card.append(key, link); + list.appendChild(card); + }); + } + + function scheduleIssueDuplicateCheck() { + clearTimeout(duplicateCheckTimer); + const draft = currentIssueCaptureDraft(); + duplicateCheckTimer = setTimeout(async () => { + renderIssueDuplicates(await issueCapture.findDuplicates(draft)); + }, 350); + } + async function loadIssueLabels(repository, selectedIds = []) { const list = qs('#create-issue-label-list'); const status = qs('#create-issue-label-status'); @@ -1802,6 +1863,7 @@ qs('#create-issue-due-date').value = captureDraft.dueDate || ''; loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds); loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId); + scheduleIssueDuplicateCheck(); qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.'; qs('#submit-new-issue').disabled = !repositories.length; qs('#create-issue-sheet').classList.add('open'); @@ -1817,6 +1879,8 @@ return; } qs('#create-issue-sheet').classList.remove('open'); + clearTimeout(duplicateCheckTimer); + qs('#create-issue-duplicates').hidden = true; creatingIssue = false; qs('#new-issue').focus(); } @@ -2565,30 +2629,39 @@ closeCreateIssueSheet(true, !discardEditedDraft); }); ['#create-issue-title', '#create-issue-body', '#create-issue-due-date'].forEach(selector => - qs(selector).addEventListener('input', saveIssueCaptureDraft) + qs(selector).addEventListener('input', () => { + saveIssueCaptureDraft(); + if (selector === '#create-issue-title') scheduleIssueDuplicateCheck(); + }) ); qs('#create-issue-repository').addEventListener('change', event => { loadIssueLabels(event.target.value); loadIssueMilestones(event.target.value); saveIssueCaptureDraft(); + scheduleIssueDuplicateCheck(); }); qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft); qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft); + qs('#create-issue-anyway').addEventListener('click', () => { + const captureDraft = currentIssueCaptureDraft(); + issueCapture.acknowledgeDuplicates(captureDraft); + qs('#create-issue-anyway').hidden = true; + qs('#create-issue-form').requestSubmit(); + }); qs('#create-issue-form').addEventListener('submit', async event => { event.preventDefault(); - const captureDraft = { - repository: qs('#create-issue-repository').value, - title: qs('#create-issue-title').value.trim(), - body: qs('#create-issue-body').value.trim(), - labelIds: selectedIssueLabelIds(), - milestoneId: Number(qs('#create-issue-milestone').value) || null, - dueDate: qs('#create-issue-due-date').value, - }; + const captureDraft = currentIssueCaptureDraft(); if (!captureDraft.repository || !captureDraft.title) { qs('#create-issue-status').textContent = 'Choose a repository and add a title.'; qs('#create-issue-title').focus(); return; } + if (issueCapture.needsDuplicateAcknowledgement(captureDraft)) { + qs('#create-issue-status').textContent = 'Review the possible existing issues, or choose Create anyway.'; + qs('#create-issue-anyway').hidden = false; + qs('#create-issue-anyway').focus(); + return; + } const button = qs('#submit-new-issue'); button.disabled = true; qs('#create-issue-status').textContent = 'Saving for background delivery…'; diff --git a/frontend/index.html b/frontend/index.html index 922049c..60343ec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -355,6 +355,12 @@
Choose a repository to load labels.
+
The issue will be assigned to you.
diff --git a/frontend/service-worker.js b/frontend/service-worker.js index b0e6a2b..d0afc25 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v51'; +const CACHE = 'stackchain-dashboard-shell-v52'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index c215cde..c40be84 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -233,5 +233,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v51" in source + assert "stackchain-dashboard-shell-v52" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index ce3581e..6e128ab 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v51" in worker + assert "stackchain-dashboard-shell-v52" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 739bac4..2c0e7f3 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v51" in worker + assert "stackchain-dashboard-shell-v52" in worker diff --git a/tests/test_my_work.py b/tests/test_my_work.py index a6e77d6..5491d46 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -1966,6 +1966,113 @@ Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.lo assert output["stored"]["number"] == 221 +def test_issue_capture_finds_only_open_issue_duplicates_in_the_selected_repository(): + script = f""" +const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const calls = []; +const capture = createIssueCapture({{ + storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}}, + fetchJson: url => {{ + calls.push(url); + return Promise.resolve({{items:[ + {{kind:'issue', state:'open', repository:'stackchain/api', number:7, title:'Investigate checkout latency'}}, + {{kind:'pull', state:'open', repository:'stackchain/api', number:8, title:'Investigate checkout latency'}}, + {{kind:'issue', state:'closed', repository:'stackchain/api', number:9, title:'Investigate checkout latency'}}, + {{kind:'issue', state:'open', repository:'stackchain/web', number:10, title:'Investigate checkout latency'}}, + {{kind:'issue', state:'open', repository:'stackchain/api', number:11, title:'Checkout latency alert'}}, + {{kind:'issue', state:'open', repository:'stackchain/api', number:12, title:'Checkout latency follow-up'}}, + {{kind:'issue', state:'open', repository:'stackchain/api', number:13, title:'Fourth match is bounded'}}, + ]}}); + }}, +}}); +capture.findDuplicates({{repository:'stackchain/api', title:' Investigate checkout latency '}}).then(state => + process.stdout.write(JSON.stringify({{calls, state}})) +); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["calls"] == [ + "api/v1/search?q=Investigate%20checkout%20latency&limit=10" + ] + assert output["state"]["status"] == "ready" + assert [item["number"] for item in output["state"]["candidates"]] == [7, 11, 12] + + +def test_issue_capture_ignores_stale_duplicate_results_and_resets_create_anyway_acknowledgement(): + script = f""" +const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const pending = []; +const capture = createIssueCapture({{ + storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}}, + fetchJson: url => new Promise((resolve, reject) => pending.push({{url, resolve, reject}})), +}}); +const firstDraft = {{repository:'stackchain/api', title:'Checkout latency'}}; +const latestDraft = {{repository:'stackchain/api', title:'Checkout latency alert'}}; +const first = capture.findDuplicates(firstDraft); +const latest = capture.findDuplicates(latestDraft); +pending[1].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:22,title:'Checkout latency alert'}}]}}); +latest.then(latestState => {{ + const blocked = capture.needsDuplicateAcknowledgement(latestDraft); + capture.acknowledgeDuplicates(latestDraft); + const allowed = capture.needsDuplicateAcknowledgement(latestDraft); + const changedDraft = {{...latestDraft,title:'Checkout latency alert today'}}; + const changedSearch = capture.findDuplicates(changedDraft); + pending[2].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:23,title:'Checkout latency alert today'}}]}}); + changedSearch.then(() => {{ + const changed = capture.needsDuplicateAcknowledgement(changedDraft); + pending[0].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:21,title:'Old result'}}]}}); + first.then(staleState => {{ + const failure = capture.findDuplicates({{repository:'stackchain/api',title:'Network failure'}}); + pending[3].reject(new Error('offline')); + failure.then(failedState => process.stdout.write(JSON.stringify({{ + latestState, staleState, blocked, allowed, changed, failedState, + failureBlocks:capture.needsDuplicateAcknowledgement({{repository:'stackchain/api',title:'Network failure'}}), + }}))); + }}); + }}); +}}); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["latestState"]["status"] == "ready" + assert output["staleState"]["status"] == "stale" + assert output["blocked"] is True + assert output["allowed"] is False + assert output["changed"] is True + assert output["failedState"]["status"] == "failed" + assert output["failureBlocks"] is False + + +def test_issue_capture_keeps_partial_duplicate_search_advisory(): + script = f""" +const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const draft = {{repository:'stackchain/api', title:'Checkout latency'}}; +const capture = createIssueCapture({{ + storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}}, + fetchJson: () => Promise.resolve({{partial:true, items:[ + {{kind:'issue',state:'open',repository:'stackchain/api',number:24,title:'Checkout latency'}}, + ]}}), +}}); +capture.findDuplicates(draft).then(state => process.stdout.write(JSON.stringify({{ + state, blocked:capture.needsDuplicateAcknowledgement(draft), +}}))); +""" + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + output = json.loads(result.stdout) + + assert output["state"]["partial"] is True + assert [item["number"] for item in output["state"]["candidates"]] == [24] + assert output["blocked"] is False + + def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable(): payload = { "user": {"login": "timmy"}, @@ -3148,6 +3255,25 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet( assert '.create-issue-label-option' in html and 'min-height:44px' in html +@pytest.mark.anyio +async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate(): + html = await dashboard() + + assert 'id="create-issue-duplicates"' in html + assert 'id="create-issue-duplicate-list"' in html + assert 'aria-live="polite"' in html + assert 'id="create-issue-anyway"' in html and 'Create anyway' in html + assert "issueCapture.findDuplicates(draft)" in html + assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html + assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html + assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index( + "issueOutbox.enqueueDurably(captureDraft)" + ) + assert ".create-issue-duplicate-card" in html + assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html + assert "overflow-wrap:anywhere" in html + + @pytest.mark.anyio async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(): html = await dashboard() diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index c0b37de..c4ebfdf 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -105,17 +105,25 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) +def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): + source = WORKER.read_text() + + assert "stackchain-dashboard-shell-v52" in source + assert "BASE + 'static/create-issue-sheet.js'" in source + assert "BASE + 'static/dashboard.js'" in source + + def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v51" in source + assert "stackchain-dashboard-shell-v52" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v51" in source + assert "stackchain-dashboard-shell-v52" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -124,21 +132,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v51" in source + assert "stackchain-dashboard-shell-v52" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v51" in source + assert "stackchain-dashboard-shell-v52" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v51" in source + assert "stackchain-dashboard-shell-v52" in source assert "BASE + 'static/update-ownership.js'" in source diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 204beed..8b275ba 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -63,7 +63,7 @@ sync.enqueue('add', 'issue:r:1:'); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v51" in source + assert "stackchain-dashboard-shell-v52" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0