feat: queue offline checklist progress (Closes #907)
This commit is contained in:
parent
88e5f3f33b
commit
411e40d117
|
|
@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||||
);
|
);
|
||||||
const pending = new Map();
|
const pending = new Map();
|
||||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||||
|
|
||||||
function reviewFingerprint(message) {
|
function reviewFingerprint(message) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
|
@ -96,6 +96,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
blockerNumber: Number(message.blockerNumber || 0),
|
blockerNumber: Number(message.blockerNumber || 0),
|
||||||
present: message.present === true,
|
present: message.present === true,
|
||||||
} : {}),
|
} : {}),
|
||||||
|
...(message.kind === 'issue-content' ? {
|
||||||
|
title: String(message.title || ''),
|
||||||
|
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
items.push(item);
|
items.push(item);
|
||||||
write(items, mirror);
|
write(items, mirror);
|
||||||
|
|
@ -171,6 +175,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
if (item.kind === 'issue-blocker') {
|
if (item.kind === 'issue-blocker') {
|
||||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||||
}
|
}
|
||||||
|
if (item.kind === 'issue-content') {
|
||||||
|
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content';
|
||||||
|
}
|
||||||
if (item.kind === 'pull-review') {
|
if (item.kind === 'pull-review') {
|
||||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||||
}
|
}
|
||||||
|
|
@ -246,9 +253,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
decision: item.decision,
|
decision: item.decision,
|
||||||
expected_head_sha: item.expectedHeadSha,
|
expected_head_sha: item.expectedHeadSha,
|
||||||
comments: item.comments,
|
comments: item.comments,
|
||||||
|
} : item.kind === 'issue-content' ? {
|
||||||
|
title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt,
|
||||||
} : { body: item.body };
|
} : { body: item.body };
|
||||||
result = await fetchJson(endpoint(item), {
|
result = await fetchJson(endpoint(item), {
|
||||||
method: 'POST',
|
method: item.kind === 'issue-content' ? 'PATCH' : 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -256,6 +265,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
if (item.kind === 'issue-content' &&
|
||||||
|
(result?.number !== item.number || result?.title !== item.title || result?.body !== item.body)) {
|
||||||
|
throw new Error('Checklist update was not confirmed.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!result) return { blocked: true };
|
if (!result) return { blocked: true };
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,24 @@ function createBackgroundIssueSync({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (item.kind === 'issue-content') {
|
||||||
|
return {
|
||||||
|
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/content',
|
||||||
|
options: {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Idempotency-Key': item.operationId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: item.title,
|
||||||
|
body: item.body,
|
||||||
|
expected_updated_at: item.expectedUpdatedAt,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
if (item.kind === 'pull-review') {
|
if (item.kind === 'pull-review') {
|
||||||
return {
|
return {
|
||||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||||
|
|
@ -706,6 +724,12 @@ function createBackgroundIssueSync({
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (item.kind === 'issue-content' &&
|
||||||
|
(delivered?.number !== item.number || delivered?.title !== item.title || delivered?.body !== item.body)) {
|
||||||
|
const error = new Error('Checklist update was not confirmed.');
|
||||||
|
error.status = 422;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
await completeClaim(item, delivered);
|
await completeClaim(item, delivered);
|
||||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||||
|
|
|
||||||
|
|
@ -429,7 +429,11 @@
|
||||||
}
|
}
|
||||||
let reviewController = null;
|
let reviewController = null;
|
||||||
let wrapPreference = null;
|
let wrapPreference = null;
|
||||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueController = createIssueSheet({
|
||||||
|
fetchJson: fetchReviewJson,
|
||||||
|
storage: localStorage,
|
||||||
|
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||||||
|
});
|
||||||
function overdueAgendaItems() {
|
function overdueAgendaItems() {
|
||||||
return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue');
|
return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue');
|
||||||
}
|
}
|
||||||
|
|
@ -3395,7 +3399,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderIssueBody(detail) {
|
function renderIssueBody(detail) {
|
||||||
issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline &&
|
issueController.renderTasks(qs('#issue-sheet-body'), detail,
|
||||||
!issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at);
|
!issueController.readOnly(selectedIssue) && detail.state === 'open' && detail.updated_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3490,8 +3494,11 @@
|
||||||
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
|
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||||
qs('#close-issue-sheet').focus();
|
qs('#close-issue-sheet').focus();
|
||||||
try {
|
try {
|
||||||
const detail = offlineDetail || await issueController.load(item);
|
const loadedDetail = offlineDetail || await issueController.load(item);
|
||||||
if (selectedIssue !== item) return;
|
if (selectedIssue !== item) return;
|
||||||
|
const detail = issueController.pendingTask(
|
||||||
|
item, loadedDetail, authoredOutbox.list(), confirmedOwnerLogin
|
||||||
|
);
|
||||||
selectedIssueDetail = detail;
|
selectedIssueDetail = detail;
|
||||||
renderPlanIssueDependencies(detail);
|
renderPlanIssueDependencies(detail);
|
||||||
issueConversation = issueController.conversation(item, detail.conversation);
|
issueConversation = issueController.conversation(item, detail.conversation);
|
||||||
|
|
@ -5598,7 +5605,7 @@
|
||||||
});
|
});
|
||||||
issueController.bindTaskToggles({
|
issueController.bindTaskToggles({
|
||||||
container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
|
container:qs('#issue-sheet-body'), status:qs('#issue-sheet-status'), retry:qs('#retry-issue-load'),
|
||||||
current:()=>({item:selectedIssue,detail:selectedIssueDetail}),
|
current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline}),
|
||||||
confirmed:applyIssueContent,
|
confirmed:applyIssueContent,
|
||||||
restore:renderIssueBody,
|
restore:renderIssueBody,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ function escapeOptionHtml(value) {
|
||||||
})[character]);
|
})[character]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.renderMarkdown, toggleTask = renderMarkdown?.toggleTask, enqueueDurably, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||||
let commentRequest = null;
|
let commentRequest = null;
|
||||||
let closeRequest = null;
|
let closeRequest = null;
|
||||||
let releaseRequest = null;
|
let releaseRequest = null;
|
||||||
|
|
@ -152,6 +152,22 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||||
expectedUpdatedAt: detail.updated_at,
|
expectedUpdatedAt: detail.updated_at,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async queueTask(item, detail, taskIndex, checked) {
|
||||||
|
if (typeof toggleTask !== 'function' || typeof enqueueDurably !== 'function') {
|
||||||
|
throw new Error('Offline checklist updates are unavailable.');
|
||||||
|
}
|
||||||
|
const body = toggleTask(detail.body, taskIndex, checked);
|
||||||
|
await enqueueDurably({ kind:'issue-content', repository:item.repository, number:item.number,
|
||||||
|
title:detail.title, body, expectedUpdatedAt:detail.updated_at });
|
||||||
|
return { queued:true, detail:{ ...detail, body, checklist_pending:true } };
|
||||||
|
},
|
||||||
|
pendingTask(item, detail, items, ownerLogin) {
|
||||||
|
const pending = [...(items || [])].reverse().find(candidate => candidate?.kind === 'issue-content' &&
|
||||||
|
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
|
||||||
|
candidate.ownerLogin === ownerLogin && ['queued', 'sending', 'attention'].includes(candidate.status));
|
||||||
|
if (!pending) return { ...detail };
|
||||||
|
return { ...detail, title:pending.title, body:pending.body, checklist_pending:true };
|
||||||
|
},
|
||||||
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
|
bindTaskToggles({ container, status, retry, current, confirmed, restore }) {
|
||||||
container.addEventListener('change', async event => {
|
container.addEventListener('change', async event => {
|
||||||
const control = event.target.closest('input.task-list-toggle');
|
const control = event.target.closest('input.task-list-toggle');
|
||||||
|
|
@ -161,13 +177,14 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||||
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
|
container.querySelectorAll('input.task-list-toggle').forEach(input => { input.disabled = true; });
|
||||||
status.textContent = 'Updating checklist…';
|
status.textContent = 'Updating checklist…';
|
||||||
try {
|
try {
|
||||||
const result = await this.toggleTask(
|
const result = await (state.offline ? this.queueTask : this.toggleTask).call(
|
||||||
state.item, state.detail, Number(control.dataset.taskIndex), control.checked
|
this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked
|
||||||
);
|
);
|
||||||
const latest = current();
|
const latest = current();
|
||||||
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||||
confirmed(state.item, state.detail, result);
|
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||||
status.textContent = 'Checklist updated.';
|
if (state.offline) status.textContent = 'Checklist queued. Pending sync.';
|
||||||
|
else status.textContent = 'Checklist updated.';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const latest = current();
|
const latest = current();
|
||||||
|
|
@ -180,9 +197,11 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
renderTasks(container, detail, interactive) {
|
renderTasks(container, detail, interactive) {
|
||||||
container.classList.remove('checklist-pending');
|
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
|
||||||
container.innerHTML = renderMarkdown(
|
container.innerHTML = renderMarkdown(
|
||||||
detail.body || 'No description provided.', { interactiveTasks: Boolean(interactive) }
|
detail.body || 'No description provided.', {
|
||||||
|
interactiveTasks: Boolean(interactive) && detail.checklist_pending !== true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
mergeContent(snapshot, item, detail, confirmed, replace) {
|
mergeContent(snapshot, item, detail, confirmed, replace) {
|
||||||
|
|
|
||||||
|
|
@ -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/issue-sheet.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
"static/issue-attachment.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(
|
||||||
|
|
|
||||||
|
|
@ -2391,6 +2391,16 @@ async def _update_issue_content(
|
||||||
):
|
):
|
||||||
raise IssueNotAvailableError("issue not found")
|
raise IssueNotAvailableError("issue not found")
|
||||||
if issue.get("updated_at") != expected_updated_at:
|
if issue.get("updated_at") != expected_updated_at:
|
||||||
|
if issue.get("title") == title and issue.get("body", "") == body:
|
||||||
|
return {
|
||||||
|
"repository": repository,
|
||||||
|
"number": number,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"state": issue.get("state", "open"),
|
||||||
|
"updated_at": issue.get("updated_at", ""),
|
||||||
|
"url": _safe_web_url(issue.get("html_url")),
|
||||||
|
}
|
||||||
raise IssueEditConflictError("issue changed upstream")
|
raise IssueEditConflictError("issue changed upstream")
|
||||||
|
|
||||||
response = await _get_client().patch(
|
response = await _get_client().patch(
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,42 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||||
assert output["remaining"] == []
|
assert output["remaining"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
|
||||||
|
script = f"""
|
||||||
|
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
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 outbox = createAuthoredOutbox({{
|
||||||
|
storage, getOwnerLogin:()=> 'timmy',
|
||||||
|
fetchJson: async (url, options) => {{
|
||||||
|
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||||
|
return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}};
|
||||||
|
}},
|
||||||
|
}});
|
||||||
|
const queued = outbox.enqueue({{
|
||||||
|
kind:'issue-content',repository:'stackchain/dashboard',number:17,operationId:'check-op',
|
||||||
|
title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z',
|
||||||
|
}});
|
||||||
|
const restored = createAuthoredOutbox({{storage}}).list()[0];
|
||||||
|
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["queued"]["expectedUpdatedAt"] == "2026-08-15T10:00:00Z"
|
||||||
|
assert output["restored"]["title"] == "Ship"
|
||||||
|
assert output["calls"] == [{
|
||||||
|
"url": "api/v1/repos/stackchain/dashboard/issues/17/content",
|
||||||
|
"method": "PATCH",
|
||||||
|
"key": "check-op",
|
||||||
|
"body": {
|
||||||
|
"title": "Ship", "body": "- [x] Test",
|
||||||
|
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
assert output["result"]["confirmed"][0]["body"] == "- [x] Test"
|
||||||
|
assert output["remaining"] == []
|
||||||
|
|
||||||
|
|
||||||
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))});
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,41 @@ const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_app_sync_delivers_revision_checked_issue_content():
|
||||||
|
script = f"""
|
||||||
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
let item = {{
|
||||||
|
id:'check-1',operationId:'check-1',kind:'issue-content',ownerLogin:'timmy',status:'queued',
|
||||||
|
repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',expectedUpdatedAt:'2026-08-15T10:00:00Z',
|
||||||
|
}};
|
||||||
|
const state={{calls:[],completed:[]}};
|
||||||
|
const store={{
|
||||||
|
claimNext:async()=>item?{{...item}}:null,
|
||||||
|
complete:async id=>{{state.completed.push(id);item=null;}},
|
||||||
|
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||||
|
}};
|
||||||
|
const fetchJson=async(url,options={{}})=>{{
|
||||||
|
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
|
||||||
|
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||||
|
return {{repository:'stackchain/dashboard',number:17,title:'Ship',body:'- [x] Test',updated_at:'2026-08-15T11:00:00Z'}};
|
||||||
|
}};
|
||||||
|
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["state"]["calls"][1] == {
|
||||||
|
"url": "api/v1/repos/stackchain/dashboard/issues/17/content",
|
||||||
|
"method": "PATCH",
|
||||||
|
"key": "check-1",
|
||||||
|
"body": {
|
||||||
|
"title": "Ship", "body": "- [x] Test",
|
||||||
|
"expected_updated_at": "2026-08-15T10:00:00Z",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert output["state"]["completed"] == ["check-1"]
|
||||||
|
assert output["result"]["confirmed"][0]["body"] == "- [x] Test"
|
||||||
|
|
||||||
|
|
||||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
|
|
||||||
|
|
@ -664,6 +664,45 @@ async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_response():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
if request.url.path == "/api/v1/user":
|
||||||
|
return httpx.Response(200, json={"login": "timmy"})
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"number": 17,
|
||||||
|
"title": "My draft",
|
||||||
|
"body": "Draft body",
|
||||||
|
"state": "open",
|
||||||
|
"updated_at": "2026-08-07T10:02:00Z",
|
||||||
|
"assignees": [{"login": "timmy"}],
|
||||||
|
"pull_request": None,
|
||||||
|
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.update_assigned_issue(
|
||||||
|
"stackchain/api", 17, "My draft", "Draft body",
|
||||||
|
"2026-08-07T10:00:00Z",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert [(request.method, request.url.path) for request in requests] == [
|
||||||
|
("GET", "/api/v1/user"),
|
||||||
|
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||||
|
]
|
||||||
|
assert result == {
|
||||||
|
"repository": "stackchain/api", "number": 17, "title": "My draft",
|
||||||
|
"body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/issues/17",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
|
||||||
requests = []
|
requests = []
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
||||||
"renderMarkdown(detail.body || 'No description provided.')",
|
"renderMarkdown(detail.body || 'No description provided.')",
|
||||||
"renderMarkdown(item.body || 'No description provided.')",
|
"renderMarkdown(item.body || 'No description provided.')",
|
||||||
"renderMarkdown(review.body)",
|
"renderMarkdown(review.body)",
|
||||||
"issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline",
|
"issueController.renderTasks(qs('#issue-sheet-body'), detail,",
|
||||||
)
|
)
|
||||||
for path in expected_paths:
|
for path in expected_paths:
|
||||||
assert path in dashboard
|
assert path in dashboard
|
||||||
|
|
|
||||||
|
|
@ -2206,12 +2206,16 @@ async def test_mobile_issue_detail_toggles_checklist_with_touch_safe_recovery():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
controller = ISSUE_SHEET.read_text()
|
controller = ISSUE_SHEET.read_text()
|
||||||
|
|
||||||
assert "issueController.renderTasks(qs('#issue-sheet-body'), detail, !selectedIssueOffline" in html
|
assert "issueController.renderTasks(qs('#issue-sheet-body'), detail," in html
|
||||||
|
assert "!issueController.readOnly(selectedIssue) && detail.state === 'open'" in html
|
||||||
|
assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in html
|
||||||
assert "issueController.bindTaskToggles({" in html
|
assert "issueController.bindTaskToggles({" in html
|
||||||
assert "container.addEventListener('change', async event =>" in controller
|
assert "container.addEventListener('change', async event =>" in controller
|
||||||
assert "event.target.closest('input.task-list-toggle')" in controller
|
assert "event.target.closest('input.task-list-toggle')" in controller
|
||||||
assert "state.item, state.detail, Number(control.dataset.taskIndex), control.checked" in controller
|
assert "state.item, state.detail, Number(control.dataset.taskIndex), control.checked" in controller
|
||||||
assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail})" in html
|
assert "current:()=>({item:selectedIssue,detail:selectedIssueDetail,offline:selectedIssueOffline})" in html
|
||||||
|
assert "state.offline ? this.queueTask" in controller
|
||||||
|
assert "status.textContent = 'Checklist queued. Pending sync.'" in controller
|
||||||
assert "buildMyWork.replaceIssueContent" in html
|
assert "buildMyWork.replaceIssueContent" in html
|
||||||
assert "status.textContent = 'Checklist updated.'" in controller
|
assert "status.textContent = 'Checklist updated.'" in controller
|
||||||
assert "restore(state.detail)" in controller
|
assert "restore(state.detail)" in controller
|
||||||
|
|
@ -2220,6 +2224,69 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_issue_checklist_toggle_waits_for_durable_admission_and_returns_pending_detail():
|
||||||
|
script = f"""
|
||||||
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
let admit; const queued = [];
|
||||||
|
const controller = createIssueSheet({{
|
||||||
|
storage:null,
|
||||||
|
toggleTask:(body, index, checked) => body.replace(index === 0 ? '[ ]' : '[X]', checked ? '[x]' : '[ ]'),
|
||||||
|
enqueueDurably:message => {{ queued.push(message); return new Promise(resolve => admit = resolve); }},
|
||||||
|
}});
|
||||||
|
const item = {{repository:'stackchain/api', number:17}};
|
||||||
|
const detail = {{title:'Release', body:'- [ ] Build\\n- [X] Ship', updated_at:'2026-08-15T10:00:00Z'}};
|
||||||
|
let settled = false;
|
||||||
|
const pending = controller.queueTask(item, detail, 0, true).then(result => {{ settled = true; return result; }});
|
||||||
|
const before = settled;
|
||||||
|
admit({{item:{{id:'queued-check'}}}});
|
||||||
|
pending.then(result => process.stdout.write(JSON.stringify({{before,queued,result}})));
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert output["before"] is False
|
||||||
|
assert output["queued"] == [{
|
||||||
|
"kind": "issue-content", "repository": "stackchain/api", "number": 17,
|
||||||
|
"title": "Release", "body": "- [x] Build\n- [X] Ship",
|
||||||
|
"expectedUpdatedAt": "2026-08-15T10:00:00Z",
|
||||||
|
}]
|
||||||
|
assert output["result"] == {
|
||||||
|
"queued": True,
|
||||||
|
"detail": {
|
||||||
|
"title": "Release", "body": "- [x] Build\n- [X] Ship",
|
||||||
|
"updated_at": "2026-08-15T10:00:00Z", "checklist_pending": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_issue_checklist_restores_account_bound_pending_body_after_reload():
|
||||||
|
script = f"""
|
||||||
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
const controller = createIssueSheet({{storage:null}});
|
||||||
|
const detail = {{title:'Release',body:'- [ ] Build',updated_at:'old'}};
|
||||||
|
const items = [
|
||||||
|
{{kind:'issue-content',repository:'o/r',number:7,ownerLogin:'alexander',title:'Wrong',body:'- [x] Wrong',expectedUpdatedAt:'wrong',status:'queued'}},
|
||||||
|
{{kind:'issue-content',repository:'o/r',number:7,ownerLogin:'timmy',title:'Release',body:'- [x] Build',expectedUpdatedAt:'old',status:'queued'}},
|
||||||
|
];
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
restored:controller.pendingTask({{repository:'o/r',number:7}}, detail, items, 'timmy'),
|
||||||
|
isolated:controller.pendingTask({{repository:'o/r',number:7}}, detail, items, 'hou3'),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert output["restored"] == {
|
||||||
|
"title": "Release", "body": "- [x] Build", "updated_at": "old",
|
||||||
|
"checklist_pending": True,
|
||||||
|
}
|
||||||
|
assert output["isolated"] == {
|
||||||
|
"title": "Release", "body": "- [ ] Build", "updated_at": "old",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_issue_checklist_toggle_submits_exact_revision_checked_body_once():
|
def test_issue_checklist_toggle_submits_exact_revision_checked_body_once():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user