feat: resolve mobile checklist conflicts (Closes #913)
This commit is contained in:
parent
7204a426af
commit
7b27f85b39
|
|
@ -1,4 +1,4 @@
|
||||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, mergeChecklistConflict, now = () => Date.now(), maxItems = 50 }) {
|
||||||
const storageKey = 'stackchain.authored-outbox.v1';
|
const storageKey = 'stackchain.authored-outbox.v1';
|
||||||
const makeId = createOperationId || (() =>
|
const makeId = createOperationId || (() =>
|
||||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||||
|
|
@ -113,6 +113,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
} : {}),
|
} : {}),
|
||||||
...(message.kind === 'issue-content' ? {
|
...(message.kind === 'issue-content' ? {
|
||||||
title: String(message.title || ''),
|
title: String(message.title || ''),
|
||||||
|
baseBody: String(message.baseBody ?? message.body ?? ''),
|
||||||
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
|
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
|
||||||
} : {}),
|
} : {}),
|
||||||
};
|
};
|
||||||
|
|
@ -363,6 +364,51 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
return retryItem(id, currentLogin);
|
return retryItem(id, currentLogin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveIssueContentConflict(id, latest, currentLogin) {
|
||||||
|
const previousItems = read();
|
||||||
|
const item = previousItems.find(candidate => candidate.id === id);
|
||||||
|
currentLogin = String(currentLogin || '').trim();
|
||||||
|
if (!item || item.kind !== 'issue-content' || item.status !== 'attention') {
|
||||||
|
throw new Error('This checklist conflict is no longer available.');
|
||||||
|
}
|
||||||
|
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||||
|
throw new Error('Confirm the account that queued this checklist update.');
|
||||||
|
}
|
||||||
|
if (typeof mergeChecklistConflict !== 'function') {
|
||||||
|
throw new Error('Checklist conflict review is unavailable.');
|
||||||
|
}
|
||||||
|
const merged = mergeChecklistConflict({
|
||||||
|
baseBody: String(item.baseBody ?? item.body ?? ''),
|
||||||
|
localBody: String(item.body || ''),
|
||||||
|
remoteBody: String(latest?.body || ''),
|
||||||
|
});
|
||||||
|
if (merged.conflicts?.length) return merged;
|
||||||
|
if (!String(latest?.updated_at || '')) throw new Error('Latest issue revision is unavailable.');
|
||||||
|
const rebased = {
|
||||||
|
...item,
|
||||||
|
operationId: String(makeId()).slice(0, 128),
|
||||||
|
title: String(latest?.title || ''),
|
||||||
|
baseBody: String(latest?.body || ''),
|
||||||
|
body: merged.body,
|
||||||
|
expectedUpdatedAt: String(latest.updated_at),
|
||||||
|
status: 'queued',
|
||||||
|
};
|
||||||
|
delete rebased.error;
|
||||||
|
delete rebased.deliveryState;
|
||||||
|
const nextItems = previousItems.map(candidate => candidate.id === id ? rebased : candidate);
|
||||||
|
write(nextItems, false);
|
||||||
|
if (backgroundSync?.reconcile) {
|
||||||
|
try { await backgroundSync.reconcile(nextItems, 'authored'); }
|
||||||
|
catch (error) {
|
||||||
|
write(previousItems, false);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try { await backgroundSync.requestSync?.(); }
|
||||||
|
catch (_error) { /* Foreground retry remains available. */ }
|
||||||
|
}
|
||||||
|
return { ...merged, item: rebased };
|
||||||
|
}
|
||||||
|
|
||||||
function reconcileBackground(records) {
|
function reconcileBackground(records) {
|
||||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||||
const items = read().flatMap(item => {
|
const items = read().flatMap(item => {
|
||||||
|
|
@ -388,7 +434,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
return { enqueue, enqueueDurably, update, discard, flush, retry, resolveIssueContentConflict,
|
||||||
|
reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||||
|
|
|
||||||
58
frontend/checklist-conflict.js
Normal file
58
frontend/checklist-conflict.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
function mergeChecklistConflict({ baseBody, localBody, remoteBody }) {
|
||||||
|
const taskPattern = /^(\s*[-*+]\s+\[)([ xX])(\]\s+)(.*)$/;
|
||||||
|
|
||||||
|
function tasks(body) {
|
||||||
|
const entries = [];
|
||||||
|
String(body || '').split('\n').forEach((line, lineIndex) => {
|
||||||
|
const match = line.match(taskPattern);
|
||||||
|
if (!match) return;
|
||||||
|
const label = match[4].trim();
|
||||||
|
const key = label.replace(/\s+/g, ' ').toLocaleLowerCase();
|
||||||
|
if (!key) return;
|
||||||
|
entries.push({ key, label, checked: match[2].toLowerCase() === 'x', lineIndex, match });
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grouped(entries) {
|
||||||
|
const result = new Map();
|
||||||
|
entries.forEach(entry => result.set(entry.key, [...(result.get(entry.key) || []), entry]));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = grouped(tasks(baseBody));
|
||||||
|
const local = grouped(tasks(localBody));
|
||||||
|
const remoteEntries = tasks(remoteBody);
|
||||||
|
const remote = grouped(remoteEntries);
|
||||||
|
const changes = [];
|
||||||
|
const conflicts = [];
|
||||||
|
|
||||||
|
for (const [key, baseMatches] of base) {
|
||||||
|
const localMatches = local.get(key) || [];
|
||||||
|
if (baseMatches.length !== 1 || localMatches.length !== 1) continue;
|
||||||
|
if (baseMatches[0].checked === localMatches[0].checked) continue;
|
||||||
|
const remoteMatches = remote.get(key) || [];
|
||||||
|
if (remoteMatches.length !== 1) {
|
||||||
|
conflicts.push({
|
||||||
|
label: baseMatches[0].label,
|
||||||
|
reason: remoteMatches.length ? 'ambiguous' : 'missing',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changes.push({ label: remoteMatches[0].label, checked: localMatches[0].checked });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.length) return { body: null, changes, conflicts };
|
||||||
|
const desired = new Map(changes.map(change => [
|
||||||
|
change.label.replace(/\s+/g, ' ').toLocaleLowerCase(), change.checked,
|
||||||
|
]));
|
||||||
|
const lines = String(remoteBody || '').split('\n');
|
||||||
|
remoteEntries.forEach(entry => {
|
||||||
|
if (!desired.has(entry.key)) return;
|
||||||
|
const marker = desired.get(entry.key) ? 'x' : ' ';
|
||||||
|
lines[entry.lineIndex] = entry.match[1] + marker + entry.match[3] + entry.match[4];
|
||||||
|
});
|
||||||
|
return { body: lines.join('\n'), changes, conflicts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined') module.exports = mergeChecklistConflict;
|
||||||
|
|
@ -673,6 +673,7 @@
|
||||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||||
backgroundSync: backgroundIssueSync,
|
backgroundSync: backgroundIssueSync,
|
||||||
getOwnerLogin: () => confirmedOwnerLogin,
|
getOwnerLogin: () => confirmedOwnerLogin,
|
||||||
|
mergeChecklistConflict: mergeChecklistConflict,
|
||||||
});
|
});
|
||||||
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
|
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
|
||||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||||
|
|
@ -2745,6 +2746,7 @@
|
||||||
const isUnfiled = item.kind === 'unfiled-issue';
|
const isUnfiled = item.kind === 'unfiled-issue';
|
||||||
const reviewOutbox = item.outbox_kind === 'pull-review';
|
const reviewOutbox = item.outbox_kind === 'pull-review';
|
||||||
const closureOutbox = item.outbox_kind === 'issue-close';
|
const closureOutbox = item.outbox_kind === 'issue-close';
|
||||||
|
const checklistConflict = item.checklist_conflict === true;
|
||||||
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
|
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
|
||||||
const outboxActions = item.quarantined ?
|
const outboxActions = item.quarantined ?
|
||||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||||
|
|
@ -2764,6 +2766,9 @@
|
||||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</button>' +
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open current review</button>' +
|
||||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
|
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy feedback</button>' +
|
||||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||||||
|
item.kind === 'authored-outbox' && checklistConflict ?
|
||||||
|
'<button class="draft-review-checklist" data-draft-index="' + index + '" type="button">Review changes</button>' +
|
||||||
|
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||||
item.kind === 'authored-outbox' && closureOutbox ?
|
item.kind === 'authored-outbox' && closureOutbox ?
|
||||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open issue</button>' +
|
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open issue</button>' +
|
||||||
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize & close</button>' +
|
'<button class="draft-authorize" data-draft-index="' + index + '" type="button">Authorize & close</button>' +
|
||||||
|
|
@ -2873,6 +2878,42 @@
|
||||||
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
|
else applyOutboxResult(await issueOutbox.retry(item.outbox_id, activeFlushLogin));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
list.querySelectorAll('.draft-review-checklist').forEach(button => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
|
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
||||||
|
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
|
||||||
|
button.disabled = true;
|
||||||
|
qs('#my-work-action-status').textContent = 'Loading the latest checklist for review…';
|
||||||
|
try {
|
||||||
|
const latest = await issueController.load({
|
||||||
|
repository:item.repository, number:item.number,
|
||||||
|
});
|
||||||
|
const preview = mergeChecklistConflict({
|
||||||
|
baseBody:queued.baseBody, localBody:queued.body, remoteBody:latest.body,
|
||||||
|
});
|
||||||
|
if (preview.conflicts.length) {
|
||||||
|
qs('#my-work-action-status').textContent = 'Checklist changes for ' +
|
||||||
|
preview.conflicts.map(conflict => conflict.label).join(', ') +
|
||||||
|
' could not be matched safely. Open the issue to compare renamed or deleted tasks.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm('Apply ' + preview.changes.length + ' checklist change' +
|
||||||
|
(preview.changes.length === 1 ? '' : 's') + ' to the latest issue? Remote prose and other tasks will stay unchanged.')) return;
|
||||||
|
const result = await authoredOutbox.resolveIssueContentConflict(item.outbox_id, latest, activeFlushLogin);
|
||||||
|
if (result.conflicts.length) return;
|
||||||
|
const delivery = await authoredOutbox.retry(item.outbox_id, activeFlushLogin);
|
||||||
|
applyAuthoredOutboxResult(delivery);
|
||||||
|
qs('#my-work-action-status').textContent = delivery.confirmed?.length ?
|
||||||
|
'Checklist changes applied to the latest issue.' :
|
||||||
|
'The issue changed again. Review the checklist conflict against the new version.';
|
||||||
|
} catch (error) {
|
||||||
|
qs('#my-work-action-status').textContent = String(error?.message || 'Checklist review could not be loaded. Retry when online.');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
list.querySelectorAll('.draft-authorize').forEach(button => {
|
list.querySelectorAll('.draft-authorize').forEach(button => {
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
.filter(Boolean).join('\n\n');
|
.filter(Boolean).join('\n\n');
|
||||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||||
|
const checklistConflict = !quarantined && item.kind === 'issue-content' &&
|
||||||
|
item.status === 'attention' && typeof item.baseBody === 'string';
|
||||||
return {
|
return {
|
||||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||||
outbox_id: item.id,
|
outbox_id: item.id,
|
||||||
|
|
@ -162,7 +164,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
kind: 'authored-outbox',
|
kind: 'authored-outbox',
|
||||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
label: checklistConflict ? 'Checklist conflict' : item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||||
|
|
@ -172,6 +174,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||||
title: target,
|
title: target,
|
||||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||||
copy_text: copyText,
|
copy_text: copyText,
|
||||||
|
checklist_conflict: checklistConflict,
|
||||||
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
||||||
quarantined,
|
quarantined,
|
||||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||||
}
|
}
|
||||||
const body = toggleTask(detail.body, taskIndex, checked);
|
const body = toggleTask(detail.body, taskIndex, checked);
|
||||||
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||||
title:detail.title, body, expectedUpdatedAt:detail.updated_at });
|
title:detail.title, baseBody:detail.body, body, expectedUpdatedAt:detail.updated_at });
|
||||||
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||||
},
|
},
|
||||||
pendingTask(item, detail, items, ownerLogin) {
|
pendingTask(item, detail, items, ownerLogin) {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ FEATURE_SOURCES = {
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
"static/search-batch-plan.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||||
"static/issue-attachment.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
"static/issue-attachment.js", "static/checklist-conflict.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,43 @@ const outbox = createAuthoredOutbox({{
|
||||||
assert output["items"][0]["body"] == "- [x] Build\n- [ ] Test"
|
assert output["items"][0]["body"] == "- [x] Build\n- [ ] Test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_content_conflict_resolution_rebases_task_delta_and_mirrors_before_retry():
|
||||||
|
script = f"""
|
||||||
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
const mergeChecklistConflict = require({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'checklist-conflict.js'))});
|
||||||
|
const initial = {{version:2,items:[{{
|
||||||
|
id:'check-1',operationId:'old-op',kind:'issue-content',ownerLogin:'timmy',status:'attention',
|
||||||
|
repository:'o/r',number:9,title:'Ship',baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||||
|
body:'Intro\\n- [x] Build\\n- [ ] Test',expectedUpdatedAt:'old-revision',error:'Issue changed',
|
||||||
|
}}]}};
|
||||||
|
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify(initial)]]);
|
||||||
|
const mirrors = []; let syncs = 0;
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
const outbox = createAuthoredOutbox({{
|
||||||
|
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'rebased-op', mergeChecklistConflict,
|
||||||
|
backgroundSync:{{reconcile:async items=>mirrors.push(items.map(item=>({{...item}}))),requestSync:async()=>{{syncs += 1;}}}},
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
const result = await outbox.resolveIssueContentConflict('check-1', {{
|
||||||
|
title:'Ship safely', body:'Remote intro\\n- [ ] Test\\n- [ ] Build', updated_at:'latest-revision',
|
||||||
|
}}, 'timmy');
|
||||||
|
process.stdout.write(JSON.stringify({{result,item:outbox.list()[0],mirrors,syncs}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["result"]["changes"] == [{"label": "Build", "checked": True}]
|
||||||
|
assert output["item"]["status"] == "queued"
|
||||||
|
assert output["item"]["operationId"] == "rebased-op"
|
||||||
|
assert output["item"]["title"] == "Ship safely"
|
||||||
|
assert output["item"]["body"] == "Remote intro\n- [ ] Test\n- [x] Build"
|
||||||
|
assert output["item"]["baseBody"] == "Remote intro\n- [ ] Test\n- [ ] Build"
|
||||||
|
assert output["item"]["expectedUpdatedAt"] == "latest-revision"
|
||||||
|
assert "error" not in output["item"]
|
||||||
|
assert output["mirrors"][-1][0] == output["item"]
|
||||||
|
assert output["syncs"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
|
|
||||||
49
tests/test_checklist_conflict.py
Normal file
49
tests/test_checklist_conflict.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MERGER = Path(__file__).parents[1] / "frontend" / "checklist-conflict.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script: str):
|
||||||
|
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_applies_local_task_delta_to_latest_body_without_reverting_remote_edits():
|
||||||
|
script = f"""
|
||||||
|
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||||
|
const result = mergeChecklistConflict({{
|
||||||
|
baseBody:'Intro\\n- [ ] Build\\n- [ ] Test',
|
||||||
|
localBody:'Intro\\n- [x] Build\\n- [ ] Test',
|
||||||
|
remoteBody:'Updated intro\\n- [ ] Test\\n- [ ] Build\\n- [x] Document',
|
||||||
|
}});
|
||||||
|
process.stdout.write(JSON.stringify(result));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output == {
|
||||||
|
"body": "Updated intro\n- [ ] Test\n- [x] Build\n- [x] Document",
|
||||||
|
"changes": [{"label": "Build", "checked": True}],
|
||||||
|
"conflicts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_reports_missing_or_ambiguous_changed_tasks_without_writing_a_body():
|
||||||
|
script = f"""
|
||||||
|
const mergeChecklistConflict = require({json.dumps(str(MERGER))});
|
||||||
|
const missing = mergeChecklistConflict({{
|
||||||
|
baseBody:'- [ ] Ship', localBody:'- [x] Ship', remoteBody:'- [ ] Release',
|
||||||
|
}});
|
||||||
|
const duplicate = mergeChecklistConflict({{
|
||||||
|
baseBody:'- [ ] Ship', localBody:'- [x] Ship', remoteBody:'- [ ] Ship\\n- [ ] Ship',
|
||||||
|
}});
|
||||||
|
process.stdout.write(JSON.stringify({{missing,duplicate}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["missing"]["body"] is None
|
||||||
|
assert output["missing"]["conflicts"] == [{"label": "Ship", "reason": "missing"}]
|
||||||
|
assert output["duplicate"]["body"] is None
|
||||||
|
assert output["duplicate"]["conflicts"] == [{"label": "Ship", "reason": "ambiguous"}]
|
||||||
|
|
@ -121,6 +121,28 @@ process.stdout.write(JSON.stringify(drafts));
|
||||||
assert output[1]["label"] == "Queued issue"
|
assert output[1]["label"] == "Queued issue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_inbox_identifies_account_bound_checklist_conflicts_for_review():
|
||||||
|
script = f"""
|
||||||
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||||
|
const record = {{version:2,items:[{{
|
||||||
|
id:'check-1',kind:'issue-content',ownerLogin:'timmy',status:'attention',repository:'o/r',number:7,
|
||||||
|
title:'Ship',baseBody:'- [ ] Build',body:'- [x] Build',expectedUpdatedAt:'old',error:'Issue changed',queuedAt:12,
|
||||||
|
}}]}};
|
||||||
|
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify(record)]]);
|
||||||
|
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||||
|
const owned = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||||
|
const other = createDraftInbox({{storage,getCurrentLogin:()=>'alexander'}}).list()[0];
|
||||||
|
process.stdout.write(JSON.stringify({{owned,other}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["owned"]["label"] == "Checklist conflict"
|
||||||
|
assert output["owned"]["checklist_conflict"] is True
|
||||||
|
assert output["owned"]["quarantined"] is False
|
||||||
|
assert output["other"]["checklist_conflict"] is False
|
||||||
|
assert output["other"]["quarantined"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
|
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||||
LATER_AND_START = Path(__file__).parents[1] / "frontend" / "later-and-start.js"
|
LATER_AND_START = Path(__file__).parents[1] / "frontend" / "later-and-start.js"
|
||||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||||
|
FRONTEND_BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
||||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||||
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||||
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||||
|
|
@ -2224,6 +2225,22 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
|
||||||
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
|
assert '.checklist-pending .task-list-toggle { opacity:.65;' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_drafts_reviews_and_retries_an_unambiguous_checklist_conflict():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert "mergeChecklistConflict: mergeChecklistConflict" in html
|
||||||
|
assert "const checklistConflict = item.checklist_conflict === true" in html
|
||||||
|
assert 'class="draft-review-checklist"' in html
|
||||||
|
assert ">Review changes</button>" in html
|
||||||
|
assert "const latest = await issueController.load({" in html
|
||||||
|
assert "authoredOutbox.resolveIssueContentConflict(item.outbox_id, latest, activeFlushLogin)" in html
|
||||||
|
assert "window.confirm('Apply ' + preview.changes.length + ' checklist change'" in html
|
||||||
|
assert "await authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||||
|
assert "could not be matched safely" in html
|
||||||
|
assert "checklist-conflict.js" in FRONTEND_BUNDLE.read_text()
|
||||||
|
|
||||||
|
|
||||||
def test_completed_issue_checklist_offers_close_and_next_during_today():
|
def test_completed_issue_checklist_offers_close_and_next_during_today():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
|
@ -2326,7 +2343,8 @@ pending.then(result => process.stdout.write(JSON.stringify({{before,queued,resul
|
||||||
assert output["before"] is False
|
assert output["before"] is False
|
||||||
assert output["queued"] == [{
|
assert output["queued"] == [{
|
||||||
"kind": "issue-content", "repository": "stackchain/api", "number": 17,
|
"kind": "issue-content", "repository": "stackchain/api", "number": 17,
|
||||||
"title": "Release", "body": "- [x] Build\n- [X] Ship",
|
"title": "Release", "baseBody": "- [ ] Build\n- [X] Ship",
|
||||||
|
"body": "- [x] Build\n- [X] Ship",
|
||||||
"expectedUpdatedAt": "2026-08-15T10:00:00Z",
|
"expectedUpdatedAt": "2026-08-15T10:00:00Z",
|
||||||
}]
|
}]
|
||||||
assert output["result"] == {
|
assert output["result"] == {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user