feat: capture blockers while filing mobile issues (Closes #841)
This commit is contained in:
parent
35747cf9fb
commit
9ab22b97f8
|
|
@ -539,7 +539,27 @@ function createBackgroundIssueSync({
|
||||||
deliveredIssue = await requestStage(item, request.url, request.options);
|
deliveredIssue = await requestStage(item, request.url, request.options);
|
||||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
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)
|
const attachmentMarkdowns = Array.isArray(item.attachmentMarkdowns)
|
||||||
? item.attachmentMarkdowns.slice(0, attachments.length)
|
? item.attachmentMarkdowns.slice(0, attachments.length)
|
||||||
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
: (item.attachmentMarkdown ? [item.attachmentMarkdown] : []);
|
||||||
|
|
@ -664,6 +684,7 @@ function createBackgroundIssueSync({
|
||||||
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||||
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
|
await deliverIssueCapture(item) : item.attachments?.length && !item.kind ?
|
||||||
|
await deliverIssueCapture(item) : item.blockers?.length && !item.kind ?
|
||||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||||
const error = new Error('Issue closure was not confirmed.');
|
const error = new Error('Issue closure was not confirmed.');
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,28 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let duplicateRequest = 0;
|
let duplicateRequest = 0;
|
||||||
let repositorySearchRequest = 0;
|
let repositorySearchRequest = 0;
|
||||||
|
let blockerSearchRequest = 0;
|
||||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||||
let acknowledgedDuplicateKey = '';
|
let acknowledgedDuplicateKey = '';
|
||||||
const repositoryPageRequests = new Map();
|
const repositoryPageRequests = new Map();
|
||||||
const safeLabelIds = value => Array.from(new Set(
|
const safeLabelIds = value => Array.from(new Set(
|
||||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||||
)).slice(0, 20);
|
)).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 emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||||
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
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) : '';
|
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);
|
const dueDate = safeDueDate(parsed.dueDate);
|
||||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||||
if (dueDate) draft.dueDate = dueDate;
|
if (dueDate) draft.dueDate = dueDate;
|
||||||
|
const blockers = safeBlockers(parsed.blockers);
|
||||||
|
if (blockers.length) draft.blockers = blockers;
|
||||||
return draft;
|
return draft;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return {...emptyDraft(), operationId: ''};
|
return {...emptyDraft(), operationId: ''};
|
||||||
|
|
@ -74,9 +92,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
const dueDate = safeDueDate(draft?.dueDate);
|
const dueDate = safeDueDate(draft?.dueDate);
|
||||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||||
if (dueDate) safe.dueDate = dueDate;
|
if (dueDate) safe.dueDate = dueDate;
|
||||||
|
const blockers = safeBlockers(draft?.blockers);
|
||||||
|
if (blockers.length) safe.blockers = blockers;
|
||||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
||||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
.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 : ''});
|
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||||
return safe;
|
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) {
|
function duplicateKey(draft) {
|
||||||
const repository = String(draft?.repository || '').trim();
|
const repository = String(draft?.repository || '').trim();
|
||||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
@ -288,7 +324,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
|
|
||||||
return {
|
return {
|
||||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||||
searchRepositories, findDuplicates,
|
searchRepositories, searchBlockers, findDuplicates,
|
||||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||||
|
|
|
||||||
|
|
@ -672,6 +672,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
#create-issue-repository-results[hidden] { display:none; }
|
#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 { 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-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 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-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; }
|
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||||
|
|
|
||||||
|
|
@ -568,6 +568,7 @@
|
||||||
title: qs('#issue-filing-review-title'),
|
title: qs('#issue-filing-review-title'),
|
||||||
body: qs('#issue-filing-review-body'),
|
body: qs('#issue-filing-review-body'),
|
||||||
metadata: qs('#issue-filing-review-metadata'),
|
metadata: qs('#issue-filing-review-metadata'),
|
||||||
|
blockerList: qs('#issue-filing-review-blockers'),
|
||||||
status: qs('#issue-filing-review-status'),
|
status: qs('#issue-filing-review-status'),
|
||||||
document,
|
document,
|
||||||
createObjectURL: blob => URL.createObjectURL(blob),
|
createObjectURL: blob => URL.createObjectURL(blob),
|
||||||
|
|
@ -3671,6 +3672,8 @@
|
||||||
qs('#find-work').focus();
|
qs('#find-work').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let issueCaptureBlockers = [];
|
||||||
|
|
||||||
function saveIssueCaptureDraft() {
|
function saveIssueCaptureDraft() {
|
||||||
if (!issueCapture) return;
|
if (!issueCapture) return;
|
||||||
issueCapture.saveDraft({
|
issueCapture.saveDraft({
|
||||||
|
|
@ -3680,6 +3683,7 @@
|
||||||
labelIds: selectedIssueLabelIds(),
|
labelIds: selectedIssueLabelIds(),
|
||||||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||||||
dueDate: qs('#create-issue-due-date').value,
|
dueDate: qs('#create-issue-due-date').value,
|
||||||
|
blockers: issueCaptureBlockers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3691,6 +3695,7 @@
|
||||||
labelIds: selectedIssueLabelIds(),
|
labelIds: selectedIssueLabelIds(),
|
||||||
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
milestoneId: Number(qs('#create-issue-milestone').value) || null,
|
||||||
dueDate: qs('#create-issue-due-date').value,
|
dueDate: qs('#create-issue-due-date').value,
|
||||||
|
blockers: issueCaptureBlockers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3698,6 +3703,7 @@
|
||||||
let nextIssueRepositoryPage = 2;
|
let nextIssueRepositoryPage = 2;
|
||||||
let moreIssueRepositoriesAvailable = false;
|
let moreIssueRepositoriesAvailable = false;
|
||||||
let issueRepositorySearchTimer = null;
|
let issueRepositorySearchTimer = null;
|
||||||
|
let captureBlockerSearchTimer = null;
|
||||||
|
|
||||||
function appendIssueRepositories(items) {
|
function appendIssueRepositories(items) {
|
||||||
const select = qs('#create-issue-repository');
|
const select = qs('#create-issue-repository');
|
||||||
|
|
@ -3718,8 +3724,58 @@
|
||||||
|
|
||||||
function updateIssueCreateActions() {
|
function updateIssueCreateActions() {
|
||||||
const hasRepository = Boolean(qs('#create-issue-repository').value);
|
const hasRepository = Boolean(qs('#create-issue-repository').value);
|
||||||
|
const hasBlockers = issueCaptureBlockers.length > 0;
|
||||||
qs('#submit-new-issue').disabled = !hasRepository;
|
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) {
|
function renderIssueRepositoryResults(items) {
|
||||||
|
|
@ -3897,6 +3953,9 @@
|
||||||
setIssueFilingMode(Boolean(captureDraft.repository));
|
setIssueFilingMode(Boolean(captureDraft.repository));
|
||||||
qs('#create-issue-capture-status').textContent = '';
|
qs('#create-issue-capture-status').textContent = '';
|
||||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
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);
|
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||||
scheduleIssueDuplicateCheck();
|
scheduleIssueDuplicateCheck();
|
||||||
|
|
@ -5116,6 +5175,36 @@
|
||||||
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
|
status.textContent = state.items.length ? 'Choose a matching repository.' : 'No accessible repositories match.';
|
||||||
}, 250);
|
}, 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 => {
|
qs('#load-more-issue-repositories').addEventListener('click', async event => {
|
||||||
const button = event.currentTarget;
|
const button = event.currentTarget;
|
||||||
const status = qs('#create-issue-repository-status');
|
const status = qs('#create-issue-repository-status');
|
||||||
|
|
|
||||||
|
|
@ -806,6 +806,15 @@
|
||||||
<label for="create-issue-due-date">Due date <span class="small">Optional</span>
|
<label for="create-issue-due-date">Due date <span class="small">Optional</span>
|
||||||
<input id="create-issue-due-date" type="date" />
|
<input id="create-issue-due-date" type="date" />
|
||||||
</label>
|
</label>
|
||||||
|
<section class="create-issue-blockers" aria-labelledby="create-issue-blockers-heading">
|
||||||
|
<strong id="create-issue-blockers-heading">Blocked by <span class="small">Optional · Up to 5 open issues</span></strong>
|
||||||
|
<label for="create-issue-blocker-search">Search open issues
|
||||||
|
<input id="create-issue-blocker-search" type="search" maxlength="80" placeholder="Issue title or key" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div id="create-issue-blocker-results" role="listbox" aria-label="Open issue blocker results" hidden></div>
|
||||||
|
<ul id="create-issue-blocker-selected" aria-label="Selected blockers"></ul>
|
||||||
|
<div id="create-issue-blocker-status" class="small" aria-live="polite">No blockers selected.</div>
|
||||||
|
</section>
|
||||||
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
<fieldset class="create-issue-labels" id="create-issue-labels" aria-describedby="create-issue-label-status">
|
||||||
<legend>Labels <span class="small">Optional</span></legend>
|
<legend>Labels <span class="small">Optional</span></legend>
|
||||||
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
<div class="small" id="create-issue-label-status" aria-live="polite">Choose a repository to load labels.</div>
|
||||||
|
|
@ -864,6 +873,7 @@
|
||||||
<div><dt>Title</dt><dd id="issue-filing-review-title"></dd></div>
|
<div><dt>Title</dt><dd id="issue-filing-review-title"></dd></div>
|
||||||
<div><dt>Note</dt><dd id="issue-filing-review-body"></dd></div>
|
<div><dt>Note</dt><dd id="issue-filing-review-body"></dd></div>
|
||||||
<div><dt>Planning</dt><dd id="issue-filing-review-metadata"></dd></div>
|
<div><dt>Planning</dt><dd id="issue-filing-review-metadata"></dd></div>
|
||||||
|
<div><dt>Blocked by</dt><dd><ul id="issue-filing-review-blockers"></ul></dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
<section aria-labelledby="issue-filing-review-evidence-heading">
|
<section aria-labelledby="issue-filing-review-evidence-heading">
|
||||||
<h3 id="issue-filing-review-evidence-heading">Evidence in filing order</h3>
|
<h3 id="issue-filing-review-evidence-heading">Evidence in filing order</h3>
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,13 @@
|
||||||
'Due: ' + dueDate,
|
'Due: ' + dueDate,
|
||||||
'Assigned to you',
|
'Assigned to you',
|
||||||
].join(' · ');
|
].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);
|
const attachments = (draft.attachments || (draft.attachment ? [draft.attachment] : [])).filter(Boolean);
|
||||||
if (options.evidencePreview) {
|
if (options.evidencePreview) {
|
||||||
renderVisualEvidence(attachments);
|
renderVisualEvidence(attachments);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
return attachments.length ? attachments : undefined;
|
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() {
|
function read() {
|
||||||
try {
|
try {
|
||||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||||
|
|
@ -70,6 +90,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
if (attachment) item.attachment = attachment;
|
if (attachment) item.attachment = attachment;
|
||||||
const attachments = captureAttachments(draft?.attachments);
|
const attachments = captureAttachments(draft?.attachments);
|
||||||
if (attachments) item.attachments = attachments;
|
if (attachments) item.attachments = attachments;
|
||||||
|
const blockers = captureBlockers(draft?.blockers);
|
||||||
|
if (blockers) item.blockers = blockers;
|
||||||
item.operationId = item.id;
|
item.operationId = item.id;
|
||||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||||
item.milestoneId = Number(draft.milestoneId);
|
item.milestoneId = Number(draft.milestoneId);
|
||||||
|
|
@ -176,18 +198,19 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
? String(draft.dueDate) : undefined;
|
? String(draft.dueDate) : undefined;
|
||||||
const nextAttachment = captureAttachment(draft?.attachment);
|
const nextAttachment = captureAttachment(draft?.attachment);
|
||||||
const nextAttachments = captureAttachments(draft?.attachments);
|
const nextAttachments = captureAttachments(draft?.attachments);
|
||||||
|
const nextBlockers = captureBlockers(draft?.blockers);
|
||||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) ||
|
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null) ||
|
||||||
JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null);
|
JSON.stringify(item.attachments || null) !== JSON.stringify(nextAttachments || null);
|
||||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||||
|| attachmentChanged;
|
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
|
||||||
updated = {
|
updated = {
|
||||||
...item,
|
...item,
|
||||||
repository: nextRepository, title: nextTitle,
|
repository: nextRepository, title: nextTitle,
|
||||||
body: nextBody, labelIds: nextLabelIds,
|
body: nextBody, labelIds: nextLabelIds,
|
||||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||||
attachment: nextAttachment, attachments:nextAttachments,
|
attachment: nextAttachment, attachments:nextAttachments, blockers:nextBlockers,
|
||||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
};
|
};
|
||||||
|
|
@ -197,6 +220,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
if (nextDueDate === undefined) delete updated.dueDate;
|
if (nextDueDate === undefined) delete updated.dueDate;
|
||||||
if (nextAttachment === undefined) delete updated.attachment;
|
if (nextAttachment === undefined) delete updated.attachment;
|
||||||
if (nextAttachments === undefined) delete updated.attachments;
|
if (nextAttachments === undefined) delete updated.attachments;
|
||||||
|
if (nextBlockers === undefined) delete updated.blockers;
|
||||||
if (attachmentChanged) {
|
if (attachmentChanged) {
|
||||||
delete updated.attachmentMarkdown;
|
delete updated.attachmentMarkdown;
|
||||||
delete updated.attachmentMarkdowns;
|
delete updated.attachmentMarkdowns;
|
||||||
|
|
@ -285,7 +309,27 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
...(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 :
|
const attachments = Array.isArray(item.attachments) ? item.attachments :
|
||||||
(item.attachment ? [item.attachment] : []);
|
(item.attachment ? [item.attachment] : []);
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,21 @@ function createUnfiledCaptures({
|
||||||
if ((hasAttachment || validAttachments) && !attachmentStore) {
|
if ((hasAttachment || validAttachments) && !attachmentStore) {
|
||||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
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 {
|
return {
|
||||||
title, body, ownerLogin, attachment, attachments,
|
title, body, ownerLogin, attachment, attachments, blockers,
|
||||||
hasAttachment:hasAttachment || validAttachments,
|
hasAttachment:hasAttachment || validAttachments,
|
||||||
attachmentCount:validAttachments ? attachments.length : (hasAttachment ? 1 : 0),
|
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,
|
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {
|
savedAt:Number(now()), ...(prepared.hasAttachment ? {
|
||||||
hasAttachment:true, attachmentCount:prepared.attachmentCount,
|
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)];
|
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||||
|
|
@ -155,7 +170,8 @@ function createUnfiledCaptures({
|
||||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||||
throw new Error('Reconnect with the account that saved this capture.');
|
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 (!item.hasAttachment) return draft;
|
||||||
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
||||||
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,33 @@ const fetchJson=async(url,options={{}})=>{{
|
||||||
assert output["second"]["confirmed"][0]["number"] == 469
|
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():
|
def test_evidence_bundle_retry_resumes_at_failed_image_and_posts_one_ordered_comment():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_escape_returns_to_the_unchanged_issue_form():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createReview = require({json.dumps(str(MODULE))});
|
const createReview = require({json.dumps(str(MODULE))});
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,49 @@ const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{f
|
||||||
assert output["remaining"] == []
|
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():
|
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,23 @@ process.stdout.write(JSON.stringify({{listed:captures.list()[0],names:resumed.at
|
||||||
assert output["stored"] == output["notes"]
|
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():
|
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
|
||||||
script = f"""
|
script = f"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user