From 9ab22b97f8d474dc317aa912c11f93c69cb0bd72 Mon Sep 17 00:00:00 2001 From: timmy Date: Fri, 14 Aug 2026 17:33:45 +0000 Subject: [PATCH] feat: capture blockers while filing mobile issues (Closes #841) --- frontend/background-issue-sync.js | 25 +++++++- frontend/create-issue-sheet.js | 40 ++++++++++++- frontend/dashboard.css | 8 +++ frontend/dashboard.js | 91 ++++++++++++++++++++++++++++- frontend/index.html | 10 ++++ frontend/issue-filing-review.js | 7 +++ frontend/issue-outbox.js | 50 +++++++++++++++- frontend/unfiled-captures.js | 20 ++++++- tests/test_background_issue_sync.py | 27 +++++++++ tests/test_issue_filing_review.py | 16 +++++ tests/test_issue_outbox.py | 43 ++++++++++++++ tests/test_my_work.py | 46 +++++++++++++++ tests/test_unfiled_captures.py | 17 ++++++ 13 files changed, 390 insertions(+), 10 deletions(-) diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index b087a4e..18008b1 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -539,7 +539,27 @@ function createBackgroundIssueSync({ deliveredIssue = await requestStage(item, request.url, request.options); await checkpointClaim(item, current => ({ ...current, deliveredIssue })); } - const attachments = Array.isArray(item.attachments) ? item.attachments : [item.attachment]; + const blockers = Array.isArray(item.blockers) ? item.blockers : []; + let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length); + for (let index = deliveredBlockers; index < blockers.length; index += 1) { + const blocker = blockers[index]; + await requestStage( + item, + base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/blockers', + { + method:'PATCH', + headers:{ + Accept:'application/json', 'Content-Type':'application/json', + 'Idempotency-Key':stageOperationId(item.operationId, 'blocker-' + index), + }, + body:JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}), + }, + ); + deliveredBlockers = index + 1; + await checkpointClaim(item, current => ({ ...current, deliveredIssue, deliveredBlockers })); + } + const attachments = (Array.isArray(item.attachments) ? item.attachments : [item.attachment]).filter(Boolean); + if (!attachments.length) return deliveredIssue; const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns) ? item.attachmentMarkdowns.slice(0, attachments.length) : (item.attachmentMarkdown ? [item.attachmentMarkdown] : []); @@ -664,7 +684,8 @@ function createBackgroundIssueSync({ item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ? await deliverScreenshotComment(item) : item.attachment && !item.kind ? await deliverIssueCapture(item) : item.attachments?.length && !item.kind ? - await deliverIssueCapture(item) : await requestStage(item, request.url, request.options); + await deliverIssueCapture(item) : item.blockers?.length && !item.kind ? + await deliverIssueCapture(item) : await requestStage(item, request.url, request.options); if (item.kind === 'issue-close' && delivered?.state !== 'closed') { const error = new Error('Issue closure was not confirmed.'); error.status = 422; diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js index 7d802a7..ee63d05 100644 --- a/frontend/create-issue-sheet.js +++ b/frontend/create-issue-sheet.js @@ -26,12 +26,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp let pending = null; let duplicateRequest = 0; let repositorySearchRequest = 0; + let blockerSearchRequest = 0; let duplicateState = {status: 'idle', key: '', candidates: []}; let acknowledgedDuplicateKey = ''; const repositoryPageRequests = new Map(); const safeLabelIds = value => Array.from(new Set( (Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0) )).slice(0, 20); + const safeBlockers = value => { + const seen = new Set(); + return (Array.isArray(value) ? value : []).reduce((items, blocker) => { + const repository = String(blocker?.repository || '').trim(); + const number = Number(blocker?.number); + const key = repository + '#' + number; + if (items.length >= 5 || seen.has(key) || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || + !Number.isInteger(number) || number < 1) return items; + seen.add(key); + items.push({repository, number, + title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)}); + return items; + }, []); + }; const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] }); const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null; const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : ''; @@ -51,6 +67,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp const dueDate = safeDueDate(parsed.dueDate); if (milestoneId !== null) draft.milestoneId = milestoneId; if (dueDate) draft.dueDate = dueDate; + const blockers = safeBlockers(parsed.blockers); + if (blockers.length) draft.blockers = blockers; return draft; } catch (_error) { return {...emptyDraft(), operationId: ''}; @@ -74,9 +92,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp const dueDate = safeDueDate(draft?.dueDate); if (milestoneId !== null) safe.milestoneId = milestoneId; if (dueDate) safe.dueDate = dueDate; + const blockers = safeBlockers(draft?.blockers); + if (blockers.length) safe.blockers = blockers; const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate'] .every(key => (previous[key] || '') === (safe[key] || '')) && - JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds); + JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) && + JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []); writeStored({...safe, operationId: unchanged ? previous.operationId : ''}); return safe; } @@ -216,6 +237,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp } } + async function searchBlockers(value) { + const query = String(value || '').trim().slice(0, 80); + const request = ++blockerSearchRequest; + if (query.length < 2) return {status:'idle', items:[]}; + try { + const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(query) + '&limit=20'); + if (request !== blockerSearchRequest) return {status:'stale', items:[]}; + return {status:'ready', items:(Array.isArray(payload?.items) ? payload.items : []) + .filter(item => item?.kind === 'issue' && item?.state === 'open').slice(0, 20)}; + } catch (error) { + if (request !== blockerSearchRequest) return {status:'stale', items:[]}; + return {status:'failed', items:[], error}; + } + } + function duplicateKey(draft) { const repository = String(draft?.repository || '').trim(); const title = String(draft?.title || '').replace(/\s+/g, ' ').trim(); @@ -288,7 +324,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp return { saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, - searchRepositories, findDuplicates, + searchRepositories, searchBlockers, findDuplicates, needsDuplicateAcknowledgement, acknowledgeDuplicates, submit, stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent, stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp, diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b06d92a..820188c 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -672,6 +672,14 @@ textarea { resize: vertical; min-height: 120px; } #create-issue-repository-results[hidden] { display:none; } .create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; } .create-issue-repository-result:last-child { border-bottom:0; } +.create-issue-blockers { display:grid; gap:8px; min-width:0; } +#create-issue-blocker-search { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } +#create-issue-blocker-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; } +#create-issue-blocker-results[hidden] { display:none; } +.create-issue-blocker-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; } +#create-issue-blocker-selected { display:grid; gap:8px; margin:0; padding:0; list-style:none; min-width:0; } +.create-issue-blocker-selected { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; min-width:0; overflow-wrap:anywhere; } +.create-issue-blocker-selected button { min-height:44px; } .create-issue-form label { display:grid; gap:6px; } .create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } .create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 99f9cd7..ea84ae6 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -568,6 +568,7 @@ title: qs('#issue-filing-review-title'), body: qs('#issue-filing-review-body'), metadata: qs('#issue-filing-review-metadata'), + blockerList: qs('#issue-filing-review-blockers'), status: qs('#issue-filing-review-status'), document, createObjectURL: blob => URL.createObjectURL(blob), @@ -3671,6 +3672,8 @@ qs('#find-work').focus(); } + let issueCaptureBlockers = []; + function saveIssueCaptureDraft() { if (!issueCapture) return; issueCapture.saveDraft({ @@ -3680,6 +3683,7 @@ labelIds: selectedIssueLabelIds(), milestoneId: Number(qs('#create-issue-milestone').value) || null, dueDate: qs('#create-issue-due-date').value, + blockers: issueCaptureBlockers, }); } @@ -3691,6 +3695,7 @@ labelIds: selectedIssueLabelIds(), milestoneId: Number(qs('#create-issue-milestone').value) || null, dueDate: qs('#create-issue-due-date').value, + blockers: issueCaptureBlockers, }; } @@ -3698,6 +3703,7 @@ let nextIssueRepositoryPage = 2; let moreIssueRepositoriesAvailable = false; let issueRepositorySearchTimer = null; + let captureBlockerSearchTimer = null; function appendIssueRepositories(items) { const select = qs('#create-issue-repository'); @@ -3718,8 +3724,58 @@ function updateIssueCreateActions() { const hasRepository = Boolean(qs('#create-issue-repository').value); + const hasBlockers = issueCaptureBlockers.length > 0; qs('#submit-new-issue').disabled = !hasRepository; - qs('#create-and-start-issue').disabled = !hasRepository || !createAndStart.available(); + qs('#create-and-start-issue').disabled = !hasRepository || hasBlockers || !createAndStart.available(); + qs('#create-and-start-issue').title = hasBlockers ? 'Blocked work cannot start until its blockers are complete.' : ''; + } + + function renderIssueCaptureBlockers(blockers) { + issueCaptureBlockers = Array.isArray(blockers) ? blockers.slice(0, 5) : []; + const selected = qs('#create-issue-blocker-selected'); + selected.replaceChildren(...issueCaptureBlockers.map((blocker, index) => { + const item = document.createElement('li'); + item.className = 'create-issue-blocker-selected'; + const text = document.createElement('span'); + text.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.textContent = 'Remove'; + remove.setAttribute('aria-label', 'Remove blocker ' + blocker.repository + ' #' + blocker.number); + remove.addEventListener('click', () => { + renderIssueCaptureBlockers(issueCaptureBlockers.filter((_value, position) => position !== index)); + saveIssueCaptureDraft(); + }); + item.append(text, remove); + return item; + })); + qs('#create-issue-blocker-status').textContent = issueCaptureBlockers.length ? + issueCaptureBlockers.length + ' blocker' + (issueCaptureBlockers.length === 1 ? '' : 's') + + ' selected. Blocked work will be created without starting.' : 'No blockers selected.'; + updateIssueCreateActions(); + } + + function renderIssueCaptureBlockerResults(items) { + const results = qs('#create-issue-blocker-results'); + results.replaceChildren(); + (Array.isArray(items) ? items : []).filter(candidate => !issueCaptureBlockers.some(blocker => + blocker.repository === candidate.repository && blocker.number === Number(candidate.number))).forEach(candidate => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'create-issue-blocker-result'; + button.setAttribute('role', 'option'); + button.textContent = candidate.repository + ' #' + candidate.number + ' — ' + candidate.title; + button.addEventListener('click', () => { + renderIssueCaptureBlockers([...issueCaptureBlockers, { + repository:candidate.repository, number:Number(candidate.number), title:candidate.title, + }]); + results.hidden = true; + qs('#create-issue-blocker-search').value = ''; + saveIssueCaptureDraft(); + }); + results.appendChild(button); + }); + results.hidden = !results.childElementCount; } function renderIssueRepositoryResults(items) { @@ -3897,6 +3953,9 @@ setIssueFilingMode(Boolean(captureDraft.repository)); qs('#create-issue-capture-status').textContent = ''; qs('#create-issue-due-date').value = captureDraft.dueDate || ''; + renderIssueCaptureBlockers(captureDraft.blockers || []); + qs('#create-issue-blocker-search').value = ''; + qs('#create-issue-blocker-results').hidden = true; loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds); loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId); scheduleIssueDuplicateCheck(); @@ -5116,6 +5175,36 @@ status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.'; }, 250); }); + qs('#create-issue-blocker-search').addEventListener('input', event => { + clearTimeout(captureBlockerSearchTimer); + const query = event.target.value.trim(); + const results = qs('#create-issue-blocker-results'); + const status = qs('#create-issue-blocker-status'); + if (query.length < 2) { + issueCapture.searchBlockers(query); + results.hidden = true; + status.textContent = issueCaptureBlockers.length ? issueCaptureBlockers.length + ' blocker(s) selected.' : + (query ? 'Enter at least 2 characters to search.' : 'No blockers selected.'); + return; + } + if (issueCaptureBlockers.length >= 5) { + results.hidden = true; + status.textContent = 'Five blockers selected. Remove one to choose another.'; + return; + } + status.textContent = 'Searching open issues…'; + captureBlockerSearchTimer = setTimeout(async () => { + const state = await issueCapture.searchBlockers(query); + if (state.status === 'stale') return; + if (state.status === 'failed') { + results.hidden = true; + status.textContent = 'Blocker search failed. Your draft is safe; retry.'; + return; + } + renderIssueCaptureBlockerResults(state.items); + status.textContent = state.items.length ? 'Choose an issue that must finish first.' : 'No open issues match.'; + }, 250); + }); qs('#load-more-issue-repositories').addEventListener('click', async event => { const button = event.currentTarget; const status = qs('#create-issue-repository-status'); diff --git a/frontend/index.html b/frontend/index.html index c4be4d2..41e9eee 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -806,6 +806,15 @@ +
+ Blocked by Optional · Up to 5 open issues + + + +
No blockers selected.
+
Labels Optional
Choose a repository to load labels.
@@ -864,6 +873,7 @@
Title
Note
Planning
+
Blocked by

    Evidence in filing order

    diff --git a/frontend/issue-filing-review.js b/frontend/issue-filing-review.js index a48657b..f4a1d35 100644 --- a/frontend/issue-filing-review.js +++ b/frontend/issue-filing-review.js @@ -109,6 +109,13 @@ 'Due: ' + dueDate, 'Assigned to you', ].join(' · '); + if (options.blockerList) { + options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => { + const item = options.document.createElement('li'); + item.textContent = blocker.repository + ' #' + blocker.number + ' — ' + blocker.title; + return item; + })); + } const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean); if (options.evidencePreview) { renderVisualEvidence(attachments); diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 27dee03..f0c9862 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -29,6 +29,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge return attachments.length ? attachments : undefined; } + function captureBlockers(values) { + if (!Array.isArray(values)) return undefined; + const seen = new Set(); + const blockers = []; + for (const value of values) { + const repository = String(value?.repository || '').trim(); + const number = Number(value?.number); + const key = repository + '#' + number; + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || + !Number.isInteger(number) || number < 1 || seen.has(key)) continue; + seen.add(key); + blockers.push({ + repository, number, + title: String(value?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255), + }); + if (blockers.length === 5) break; + } + return blockers.length ? blockers : undefined; + } + function read() { try { const record = JSON.parse(storage?.getItem(storageKey) || 'null'); @@ -70,6 +90,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (attachment) item.attachment = attachment; const attachments = captureAttachments(draft?.attachments); if (attachments) item.attachments = attachments; + const blockers = captureBlockers(draft?.blockers); + if (blockers) item.blockers = blockers; item.operationId = item.id; if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) { item.milestoneId = Number(draft.milestoneId); @@ -176,18 +198,19 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge ? String(draft.dueDate) : undefined; const nextAttachment = captureAttachment(draft?.attachment); const nextAttachments = captureAttachments(draft?.attachments); + const nextBlockers = captureBlockers(draft?.blockers); const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) || JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null); const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody || JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds) || item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate - || attachmentChanged; + || attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null); updated = { ...item, repository: nextRepository, title: nextTitle, body: nextBody, labelIds: nextLabelIds, milestoneId: nextMilestoneId, dueDate: nextDueDate, - attachment: nextAttachment, attachments:nextAttachments, + attachment: nextAttachment, attachments:nextAttachments, blockers:nextBlockers, operationId: changed ? String(operationId()).slice(0, 128) : item.operationId, status: 'queued', }; @@ -197,6 +220,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge if (nextDueDate === undefined) delete updated.dueDate; if (nextAttachment === undefined) delete updated.attachment; if (nextAttachments === undefined) delete updated.attachments; + if (nextBlockers === undefined) delete updated.blockers; if (attachmentChanged) { delete updated.attachmentMarkdown; delete updated.attachmentMarkdowns; @@ -285,7 +309,27 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), }), }); - if (item.attachment || item.attachments) persistDeliveryStage(item.id, { deliveredIssue: issue }); + if (item.attachment || item.attachments || item.blockers?.length) { + persistDeliveryStage(item.id, { deliveredIssue: issue }); + } + } + const blockers = Array.isArray(item.blockers) ? item.blockers : []; + let deliveredBlockers = Math.min(Number(item.deliveredBlockers) || 0, blockers.length); + for (let index = deliveredBlockers; index < blockers.length; index += 1) { + const blocker = blockers[index]; + await fetchJson( + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/blockers', + { + method: 'PATCH', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'Idempotency-Key': stageOperationId(item.operationId, 'blocker-' + index), + }, + body: JSON.stringify({repository:blocker.repository, number:blocker.number, present:true}), + }, + ); + deliveredBlockers = index + 1; + persistDeliveryStage(item.id, { deliveredIssue:issue, deliveredBlockers }); } const attachments = Array.isArray(item.attachments) ? item.attachments : (item.attachment ? [item.attachment] : []); diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js index b6e3295..a8136b5 100644 --- a/frontend/unfiled-captures.js +++ b/frontend/unfiled-captures.js @@ -58,8 +58,21 @@ function createUnfiledCaptures({ if ((hasAttachment || validAttachments) && !attachmentStore) { throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.'); } + const seenBlockers = new Set(); + const blockers = (Array.isArray(note?.blockers) ? note.blockers : []).reduce((items, blocker) => { + const repository = String(blocker?.repository || '').trim(); + const number = Number(blocker?.number); + const key = repository + '#' + number; + if (items.length >= 5 || seenBlockers.has(key) || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || + !Number.isInteger(number) || number < 1) return items; + seenBlockers.add(key); + items.push({repository, number, + title:String(blocker?.title || '').replace(/\s+/g, ' ').trim().slice(0, 255)}); + return items; + }, []); return { - title, body, ownerLogin, attachment, attachments, + title, body, ownerLogin, attachment, attachments, blockers, hasAttachment:hasAttachment || validAttachments, attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0), }; @@ -70,6 +83,8 @@ function createUnfiledCaptures({ id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body, savedAt:Number(now()), ...(prepared.hasAttachment ? { hasAttachment:true, attachmentCount:prepared.attachmentCount, + } : {}), ...(prepared.blockers.length ? { + blockers:prepared.blockers, blockerCount:prepared.blockers.length, } : {}), }; const items = [item, ...existing.filter(candidate => candidate.id !== item.id)]; @@ -155,7 +170,8 @@ function createUnfiledCaptures({ if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) { throw new Error('Reconnect with the account that saved this capture.'); } - const draft = {repository:'', title:item.title, body:item.body, labelIds:[]}; + const draft = {repository:'', title:item.title, body:item.body, labelIds:[], + ...(Array.isArray(item.blockers) && item.blockers.length ? {blockers:item.blockers} : {})}; if (!item.hasAttachment) return draft; if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.'); return Promise.resolve(attachmentStore.get(id)).then(attachment => { diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index aef1ad1..2878089 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -164,6 +164,33 @@ const fetchJson=async(url,options={{}})=>{{ assert output["second"]["confirmed"][0]["number"] == 469 +def test_closed_app_capture_retries_only_unfinished_blockers_after_creation(): + script = f""" +const createBackgroundIssueSync=require({json.dumps(str(SYNC))}); +let item={{id:'blocked',operationId:'blocked',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Blocked',body:'',labelIds:[],blockers:[ + {{repository:'o/api',number:7,title:'API'}},{{repository:'o/web',number:8,title:'Web'}} +]}}; +const calls=[];let secondAttempts=0; +const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}}; +const fetchJson=async(url,options={{}})=>{{ + if(url==='api/v1/background-identity')return{{login:'timmy'}}; + const body=options.body?JSON.parse(options.body):null;calls.push({{url,key:options.headers?.['Idempotency-Key'],body}}); + if(url.endsWith('/issues'))return{{number:42,repository:'o/r'}}; + if(body.number===8 && secondAttempts++===0){{const e=new Error('offline');e.status=503;throw e;}} + return{{number:42,dependencies_available:true,dependencies:[body]}}; +}}; +(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';try{{await sync.flush();}}catch(e){{first=e.message;}}const checkpoint={{...item}};const result=await sync.flush();process.stdout.write(JSON.stringify({{first,checkpoint,calls,result}}));}})(); +""" + output = run_node(script) + assert output["first"] == "offline" + assert output["checkpoint"]["deliveredIssue"]["number"] == 42 + assert output["checkpoint"]["deliveredBlockers"] == 1 + assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1 + blockers = [call for call in output["calls"] if call["url"].endswith("/blockers")] + assert [call["body"]["number"] for call in blockers] == [7, 8, 8] + assert output["result"]["confirmed"][0]["number"] == 42 + + def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment(): script = f""" const createBackgroundIssueSync=require({json.dumps(str(SYNC))}); diff --git a/tests/test_issue_filing_review.py b/tests/test_issue_filing_review.py index d65cb22..c9e9a3b 100644 --- a/tests/test_issue_filing_review.py +++ b/tests/test_issue_filing_review.py @@ -69,6 +69,22 @@ setImmediate(()=>process.stdout.write(JSON.stringify({{ } +def test_review_lists_selected_blockers_in_filing_order(): + script = f""" +const createReview=require({json.dumps(str(MODULE))}); +function target(){{const listeners={{}};return{{hidden:true,disabled:false,textContent:'',children:[],addEventListener:(n,f)=>listeners[n]=f,replaceChildren(...items){{this.children=items;}},focus(){{}}}};}} +const blockerList=target(); +const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),blockerList, + repository:target(),intent:target(),title:target(),body:target(),metadata:target(), + document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}}); +review.open({{draft:{{repository:'o/r',title:'Blocked',blockers:[ + {{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}} +]}},intent:'create-and-assign'}},target()); +process.stdout.write(JSON.stringify(blockerList.children.map(item=>item.textContent))); +""" + assert run_node(script) == ["o/api #7 — API ready", "o/web #8 — Web ready"] + + def test_escape_returns_to_the_unchanged_issue_form(): script = f""" const createReview = require({json.dumps(str(MODULE))}); diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 08a1319..b58d3a9 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -349,6 +349,49 @@ const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{f assert output["remaining"] == [] +def test_foreground_blocker_retry_checkpoints_each_relationship_without_recreating_issue(): + script = f""" +const createIssueOutbox=require({json.dumps(str(OUTBOX))}); +const values=new Map();const calls=[];let secondAttempts=0; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const outbox=createIssueOutbox({{ + storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'blocked-op', + fetchJson:async(url,options={{}})=>{{ + const body=options.body ? JSON.parse(options.body) : null; + calls.push({{url,key:options.headers?.['Idempotency-Key'],body}}); + if(url.endsWith('/issues'))return{{repository:'o/r',number:42,title:'Blocked work'}}; + if(body?.number===8 && secondAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}} + return{{number:42,dependencies_available:true,dependencies:[body]}}; + }}, +}}); +const blockers=[ + {{repository:'o/api',number:7,title:'API ready'}}, + {{repository:'o/web',number:8,title:'Web ready'}}, +]; +const queued=outbox.enqueue({{repository:'o/r',title:'Blocked work',body:'',blockers}}); +(async()=>{{ + await outbox.flush('timmy');const partial=outbox.list()[0]; + const result=await outbox.retry(queued.id,'timmy'); + process.stdout.write(JSON.stringify({{queued,partial,result,calls,remaining:outbox.list()}})); +}})(); +""" + output = run_node(script) + + assert output["queued"]["blockers"] == [ + {"repository": "o/api", "number": 7, "title": "API ready"}, + {"repository": "o/web", "number": 8, "title": "Web ready"}, + ] + assert output["partial"]["deliveredIssue"]["number"] == 42 + assert output["partial"]["deliveredBlockers"] == 1 + assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1 + blocker_calls = [call for call in output["calls"] if call["url"].endswith("/blockers")] + assert [call["body"]["number"] for call in blocker_calls] == [7, 8, 8] + assert blocker_calls[0]["key"] == "blocked-op:blocker-0" + assert blocker_calls[-1]["key"] == "blocked-op:blocker-1" + assert output["result"]["confirmed"][0]["number"] == 42 + assert output["remaining"] == [] + + def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage(): script = f""" const createIssueOutbox=require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_my_work.py b/tests/test_my_work.py index 4748875..60c581c 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4023,6 +4023,52 @@ async def test_mobile_issue_capture_requires_explicit_searchable_repository_sele assert '.create-issue-repository-picker { min-width:0;' in html +def test_issue_capture_persists_bounded_blockers_and_searches_open_issues(): + script = f""" +const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))}); +const values=new Map();const calls=[]; +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const capture=createIssueCapture({{storage,fetchJson:async url=>{{calls.push(url);return{{items:[ + {{kind:'issue',state:'open',repository:'o/api',number:7,title:'API ready'}}, + {{kind:'pull',state:'open',repository:'o/web',number:8,title:'Not an issue'}}, + {{kind:'issue',state:'closed',repository:'o/old',number:9,title:'Closed'}} +]}};}}}}); +const blockers=[ + {{repository:'o/api',number:7,title:'API ready'}}, + {{repository:'o/api',number:7,title:'duplicate'}}, + {{repository:'bad',number:2,title:'invalid'}}, +]; +capture.saveDraft({{repository:'o/r',title:'Blocked work',body:'',blockers}}); +(async()=>{{const found=await capture.searchBlockers('api');process.stdout.write(JSON.stringify({{draft:capture.loadDraft(),found,calls}}));}})(); +""" + output = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + assert output["draft"]["blockers"] == [ + {"repository": "o/api", "number": 7, "title": "API ready"} + ] + assert output["found"]["items"] == [ + {"kind": "issue", "state": "open", "repository": "o/api", "number": 7, "title": "API ready"} + ] + assert output["calls"] == ["api/v1/search?q=api&limit=20"] + + +@pytest.mark.anyio +async def test_mobile_issue_capture_selects_blockers_without_starting_blocked_work(): + html = await dashboard() + assert 'id="create-issue-blocker-search" type="search"' in html + assert 'id="create-issue-blocker-results" role="listbox"' in html + assert 'id="create-issue-blocker-selected"' in html + assert 'id="create-issue-blocker-status" class="small" aria-live="polite"' in html + assert "issueCapture.searchBlockers(query)" in html + assert "blockers: issueCaptureBlockers" in html + assert "renderIssueCaptureBlockers(captureDraft.blockers || [])" in html + assert "const hasBlockers = issueCaptureBlockers.length > 0;" in html + assert ".create-issue-blocker-result" in html + assert "min-height:44px" in html.split(".create-issue-blocker-result", 1)[1] + assert "@media(max-width:320px)" in html + + @pytest.mark.anyio async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor(): html = await dashboard() diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index fad8045..de27cdd 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -104,6 +104,23 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at assert output["stored"] == output["notes"] +def test_unfiled_capture_restores_selected_blockers(): + script = f""" +const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); +const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const captures=createUnfiledCaptures({{storage,getCaptureLogin:()=>'timmy',getCurrentLogin:()=>'timmy',createId:()=>'blocked'}}); +const blockers=[{{repository:'o/api',number:7,title:'API ready'}},{{repository:'o/web',number:8,title:'Web ready'}}]; +const saved=captures.save({{title:'Blocked work',body:'Context',blockers}}); +const resumed=captures.resume(saved.id,'timmy'); +process.stdout.write(JSON.stringify({{listed:captures.list()[0],resumed}})); +""" + output = run_node(script) + assert output["listed"]["blockerCount"] == 2 + assert output["resumed"]["blockers"] == [ + {"repository": "o/api", "number": 7, "title": "API ready"}, + {"repository": "o/web", "number": 8, "title": "Web ready"}, + ] + def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable(): script = f""" -- 2.43.0