feat: queue offline checklist progress (Closes #907)
This commit is contained in:
1 parent
88e5f3f33b
commit
411e40d117
11 files changed
+267
-17
No files matched your search
@@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
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) {
|
||||
return JSON.stringify({
|
||||
@@ -96,6 +96,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
...(message.kind === 'issue-content' ? {
|
||||
title: String(message.title || ''),
|
||||
expectedUpdatedAt: String(message.expectedUpdatedAt || ''),
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -171,6 +175,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (item.kind === 'issue-blocker') {
|
||||
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') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
@@ -246,9 +253,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments,
|
||||
} : item.kind === 'issue-content' ? {
|
||||
title:item.title, body:item.body, expected_updated_at:item.expectedUpdatedAt,
|
||||
} : { body: item.body };
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
method: item.kind === 'issue-content' ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
@@ -256,6 +265,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
},
|
||||
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 };
|
||||
|
||||
@@ -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') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
@@ -706,6 +724,12 @@ function createBackgroundIssueSync({
|
||||
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);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
|
||||
+11
-4
@@ -429,7 +429,11 @@
|
||||
}
|
||||
let reviewController = 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() {
|
||||
return agendaMyWork(activeMyWork).filter(item => item.agenda_group === 'Overdue');
|
||||
}
|
||||
@@ -3395,7 +3399,7 @@
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3490,8 +3494,11 @@
|
||||
qs('#release-issue').textContent = workSession.checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#close-issue-sheet').focus();
|
||||
try {
|
||||
const detail = offlineDetail || await issueController.load(item);
|
||||
const loadedDetail = offlineDetail || await issueController.load(item);
|
||||
if (selectedIssue !== item) return;
|
||||
const detail = issueController.pendingTask(
|
||||
item, loadedDetail, authoredOutbox.list(), confirmedOwnerLogin
|
||||
);
|
||||
selectedIssueDetail = detail;
|
||||
renderPlanIssueDependencies(detail);
|
||||
issueConversation = issueController.conversation(item, detail.conversation);
|
||||
@@ -5598,7 +5605,7 @@
|
||||
});
|
||||
issueController.bindTaskToggles({
|
||||
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,
|
||||
restore:renderIssueBody,
|
||||
});
|
||||
|
||||
+26
-7
@@ -24,7 +24,7 @@ function escapeOptionHtml(value) {
|
||||
})[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 closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
@@ -152,6 +152,22 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||
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 }) {
|
||||
container.addEventListener('change', async event => {
|
||||
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; });
|
||||
status.textContent = 'Updating checklist…';
|
||||
try {
|
||||
const result = await this.toggleTask(
|
||||
state.item, state.detail, Number(control.dataset.taskIndex), control.checked
|
||||
const result = await (state.offline ? this.queueTask : this.toggleTask).call(
|
||||
this, state.item, state.detail, Number(control.dataset.taskIndex), control.checked
|
||||
);
|
||||
const latest = current();
|
||||
if (latest?.item?.repository === state.item.repository && latest.item.number === state.item.number) {
|
||||
confirmed(state.item, state.detail, result);
|
||||
status.textContent = 'Checklist updated.';
|
||||
confirmed(state.item, state.detail, state.offline ? result.detail : result);
|
||||
if (state.offline) status.textContent = 'Checklist queued. Pending sync.';
|
||||
else status.textContent = 'Checklist updated.';
|
||||
}
|
||||
} catch (error) {
|
||||
const latest = current();
|
||||
@@ -180,9 +197,11 @@ function createIssueSheet({ fetchJson, storage, renderMarkdown = globalThis.rend
|
||||
});
|
||||
},
|
||||
renderTasks(container, detail, interactive) {
|
||||
container.classList.remove('checklist-pending');
|
||||
container.classList.toggle('checklist-pending', detail.checklist_pending === true);
|
||||
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) {
|
||||
|
||||
Reference in new issue
Block a user