From d96a77c5d5e500049f3513083e04ea8e880bc806 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 16:32:50 +0000 Subject: [PATCH] feat: create follow-up work from updates (Closes #663) --- frontend/create-issue-sheet.js | 38 ++++++++++++ frontend/dashboard.js | 33 ++++++++++- frontend/index.html | 2 + frontend/service-worker.js | 1 + frontend/update-follow-up.js | 32 ++++++++++ tests/test_service_worker.py | 1 + tests/test_update_follow_up.py | 104 +++++++++++++++++++++++++++++++++ 7 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 frontend/update-follow-up.js create mode 100644 tests/test_update_follow_up.py diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index a1e17fc..7d802a7 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -22,6 +22,7 @@ function normalizeSharedContent(value = {}) { function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) { const storageKey = 'stackchain.issue-capture.v1'; const sharedStorageKey = 'stackchain.issue-share.v1'; + const followUpStorageKey = 'stackchain.issue-follow-up.v1'; let pending = null; let duplicateRequest = 0; let repositorySearchRequest = 0; @@ -126,6 +127,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp return {status: 'ready'}; } + function pendingFollowUp() { + try { + const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null'); + if (!parsed || typeof parsed !== 'object') return null; + const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : ''; + const title = String(parsed.title || '').trim().slice(0, 240); + const body = String(parsed.body || '').trim().slice(0, 9500); + return title || body ? {repository, title, body, labelIds: []} : null; + } catch (_error) { return null; } + } + + function acceptFollowUp() { + const followUp = pendingFollowUp(); + if (!followUp) return loadDraft(); + const accepted = saveDraft(followUp); + try { storage.removeItem(followUpStorageKey); } + catch (_error) { /* Accepted content is already persisted as the issue draft. */ } + return accepted; + } + + function discardFollowUp() { + try { storage.removeItem(followUpStorageKey); } + catch (_error) { /* The existing capture remains authoritative. */ } + } + + function stageFollowUp(value) { + const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')}; + if (!followUp.title && !followUp.body) return {status: 'empty'}; + try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); } + catch (_error) { /* The in-page flow can still continue. */ } + const existing = loadDraft(); + if (existing.title || existing.body) return {status: 'conflict'}; + acceptFollowUp(); + return {status: 'ready'}; + } + function loadLabels(repository) { const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/'); const priorities = new Set(['p0', 'priority-high', 'critical']); @@ -254,6 +291,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp searchRepositories, findDuplicates, needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, + stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp, }; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 3d9d3af..a97f3c5 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -142,6 +142,8 @@ let reviewTrigger = null; let offlineReview = false; let selectedUpdate = null; + let selectedUpdateDetail = null; + let followUpSourceUpdate = null; let updateTrigger = null; let selectedIssue = null; let selectedIssueOffline = false; @@ -808,6 +810,7 @@ updateReplyAttachmentController.clear(); } selectedUpdate = item; + selectedUpdateDetail = null; updateMentions.dismiss(); qs('#update-sheet').classList.add('open'); qs('#update-sheet-key').textContent = item.key || ''; @@ -826,17 +829,20 @@ qs('#acknowledge-update-next').hidden = true; qs('#update-ownership-action').hidden = true; qs('#update-ownership-start').hidden = true; + qs('#create-update-follow-up').hidden = true; qs('#retry-update-load').hidden = true; setOfflineUpdateControls(false); qs('#keep-update-unread').focus(); }, onDetail: async detail => { + selectedUpdateDetail = detail; qs('#update-sheet-title').textContent = detail.title || 'Unread update'; qs('#update-subject-type').textContent = detail.subject_type || 'Update'; qs('#update-subject-state').textContent = detail.state || ''; qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.'); qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#'; qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported; + qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type); if (offlineWorkMode) { setOfflineUpdateControls(true); } else { @@ -3366,6 +3372,12 @@ clearTimeout(duplicateCheckTimer); qs('#create-issue-duplicates').hidden = true; creatingIssue = false; + if (followUpSourceUpdate) { + const source = followUpSourceUpdate; + followUpSourceUpdate = null; + notificationReader.open(source.item, source.detail); + return; + } qs('#new-issue').focus(); } @@ -3610,6 +3622,7 @@ updateReplyAttachmentController.clear(); qs('#update-sheet').classList.remove('open'); selectedUpdate = null; + selectedUpdateDetail = null; if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus(); else qs('[data-work-filter="update"]')?.focus(); } @@ -4165,6 +4178,22 @@ } }); qs('#new-issue').addEventListener('click', openCreateIssueSheet); + const updateFollowUp = createUpdateFollowUp(); + qs('#create-update-follow-up').addEventListener('click', async () => { + if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return; + const source = {item: selectedUpdate, detail: selectedUpdateDetail}; + const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail)); + followUpSourceUpdate = source; + closeUpdateSheet(false, false); + await openCreateIssueSheet(false); + if (state.status === 'conflict') { + qs('#shared-content-conflict').hidden = false; + qs('#create-issue-status').textContent = 'Choose which draft to continue.'; + qs('#resume-issue-draft').focus(); + } else { + qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.'; + } + }); dFS.bind(); qs('#file-new-issue').addEventListener('click', () => { if (!qs('#create-issue-title').value.trim()) { @@ -4211,7 +4240,8 @@ }); bindDraftCapacityDialog(); qs('#use-shared-content').addEventListener('click', () => { - issueCapture.acceptSharedContent(); + if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp(); + else issueCapture.acceptSharedContent(); qs('#shared-content-conflict').hidden = true; sharedLaunchState = null; clearSharedLaunchUrl(); @@ -4220,6 +4250,7 @@ }); qs('#resume-issue-draft').addEventListener('click', () => { issueCapture.discardSharedContent(); + issueCapture.discardFollowUp(); qs('#shared-content-conflict').hidden = true; sharedLaunchState = null; clearSharedLaunchUrl(); diff --git a/frontend/index.html b/frontend/index.html index 7bc97d4..67bcc2d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -683,6 +683,7 @@ + Open in Gitea
Defer
@@ -930,6 +931,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 4ae21ad..b7f2842 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -48,6 +48,7 @@ const SHELL = [ BASE + 'static/today-sync.js', BASE + 'static/today-rollover.js', BASE + 'static/update-ownership.js', + BASE + 'static/update-follow-up.js', BASE + 'static/later-work.js', BASE + 'static/later-sync.js', BASE + 'static/later-and-start.js', diff --git a/frontend/update-follow-up.js b/frontend/update-follow-up.js new file mode 100644 index 0000000..639a0a9 --- /dev/null +++ b/frontend/update-follow-up.js @@ -0,0 +1,32 @@ +function createUpdateFollowUp() { + const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit); + const safeRepository = value => { + const candidate = String(value || ''); + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) && + candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : ''; + }; + const safeUrl = value => { + try { + const parsed = new URL(String(value || '')); + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : ''; + } catch (_error) { return ''; } + }; + + function draft(detail = {}) { + const source = safeUrl(detail.url || detail.latest_comment?.url); + const context = clean(detail.latest_comment?.body || detail.subject_body, 9000); + const title = clean(detail.title, 228); + const sections = []; + if (source) sections.push('Source: ' + source); + if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> ')); + return { + repository: safeRepository(detail.repository), + title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240), + body: sections.join('\n\n').slice(0, 9500), + }; + } + + return { draft }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp; diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 7ce0b15..a895bd2 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -711,6 +711,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/today-sync.js", "/dashboard/static/today-rollover.js", "/dashboard/static/update-ownership.js", + "/dashboard/static/update-follow-up.js", "/dashboard/static/later-work.js", "/dashboard/static/later-sync.js", "/dashboard/static/later-and-start.js", diff --git a/tests/test_update_follow_up.py b/tests/test_update_follow_up.py new file mode 100644 index 0000000..7bfbfd9 --- /dev/null +++ b/tests/test_update_follow_up.py @@ -0,0 +1,104 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +ROOT = Path(__file__).parents[1] +UPDATE_FOLLOW_UP = ROOT / "frontend" / "update-follow-up.js" +CREATE_ISSUE_SHEET = ROOT / "frontend" / "create-issue-sheet.js" + + +def run_node(script: str) -> dict: + result = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, timeout=10 + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_follow_up_derives_repository_editable_title_and_canonical_context(): + script = f""" +const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))}); +const controller = createFollowUp(); +process.stdout.write(JSON.stringify(controller.draft({{ + repository: 'stackchain/stackchain-dashboard', + title: 'Fix mobile queue', + subject_type: 'Pull', + url: 'https://forge.example/git/stackchain/stackchain-dashboard/pulls/42', + latest_comment: {{ body: 'Please preserve the unread state. Ship this on mobile.' }} +}}))); +""" + assert run_node(script) == { + "repository": "stackchain/stackchain-dashboard", + "title": "Follow up: Fix mobile queue", + "body": ( + "Source: https://forge.example/git/stackchain/stackchain-dashboard/pulls/42\n\n" + "Latest context:\n> Please preserve the unread state. Ship this on mobile." + ), + } + + +def test_follow_up_rejects_unsafe_repository_and_noncanonical_source_url(): + script = f""" +const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))}); +const controller = createFollowUp(); +process.stdout.write(JSON.stringify(controller.draft({{ + repository: '../admin', title: '', + url: 'javascript:alert(1)', latest_comment: {{body: 'x'.repeat(12000)}} +}}))); +""" + output = run_node(script) + assert output["repository"] == "" + assert output["title"] == "Follow up: " + assert "javascript:" not in output["body"] + assert len(output["body"]) <= 9500 + + +def test_follow_up_staging_never_silently_overwrites_an_existing_capture(): + script = f""" +const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const values = new Map(); +const storage = {{ + getItem:key => values.has(key) ? values.get(key) : null, + setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key) +}}; +const capture = createCapture({{fetchJson:async()=>[], storage}}); +capture.saveDraft({{repository:'o/existing', title:'Existing draft', body:'Keep me'}}); +const state = capture.stageFollowUp({{repository:'o/new', title:'Follow up', body:'Source: https://example.test/1'}}); +const before = capture.loadDraft(); +const accepted = capture.acceptFollowUp(); +process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.pendingFollowUp()}})); +""" + assert run_node(script) == { + "state": {"status": "conflict"}, + "before": { + "repository": "o/existing", + "title": "Existing draft", + "body": "Keep me", + "labelIds": [], + }, + "accepted": { + "repository": "o/new", + "title": "Follow up", + "body": "Source: https://example.test/1", + "labelIds": [], + }, + "pending": None, + } + + +@pytest.mark.anyio +async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read(): + html = await dashboard() + + assert '' in html + assert 'id="create-update-follow-up"' in html + assert '>Create follow-up' in html + assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html + assert "qs('#create-update-follow-up').addEventListener('click'" in html + assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0] + assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html