feat: sync Today blockers offline (Closes #533)
CI / lint (pull_request) Successful in 1m14s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 02:29:56 +00:00
1 parent ee36b8824a
commit fff7bccf08
13 files changed
+357 -17

No files matched your search

+29 -1
View File
@@ -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']);
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
function reviewFingerprint(message) {
return JSON.stringify({
@@ -91,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
...(message.kind === 'issue-blocker' ? {
blockerRepository: String(message.blockerRepository || ''),
blockerNumber: Number(message.blockerNumber || 0),
present: message.present === true,
} : {}),
};
items.push(item);
write(items, mirror);
@@ -163,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (item.kind === 'issue-close') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
}
if (item.kind === 'issue-blocker') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
}
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
@@ -212,6 +220,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
});
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
} else if (item.kind === 'issue-blocker') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present,
}),
});
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
throw new Error('Blocker change was not confirmed.');
}
} else {
const body = item.kind === 'pull-review' ? {
body: item.body,
+28
View File
@@ -368,6 +368,24 @@ function createBackgroundIssueSync({
},
};
}
if (item.kind === 'issue-blocker') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
options: {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present === true,
}),
},
};
}
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
@@ -612,6 +630,16 @@ function createBackgroundIssueSync({
error.status = 422;
throw error;
}
if (item.kind === 'issue-blocker') {
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
const error = new Error('Blocker change 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 };
+22 -6
View File
@@ -395,6 +395,9 @@
backgroundSync: backgroundIssueSync,
getOwnerLogin: () => confirmedOwnerLogin,
});
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
});
const notificationReadOutbox = createNotificationReadOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync,
@@ -1342,7 +1345,7 @@
const addButton = qs('#add-plan-preview');
panel.hidden = false;
list.innerHTML = '';
qs('#manage-issue-blockers').hidden = !assignedIssue || selectedIssueOffline;
qs('#manage-issue-blockers').hidden = !assignedIssue;
qs('#start-unblocked-issue').hidden = true;
if (!assignedIssue) qs('#issue-blocker-manager').hidden = true;
if (assignedIssue) {
@@ -1403,11 +1406,20 @@
}
async function mutateIssueBlocker(blocker, remove, button) {
if (!selectedIssue || selectedIssueOffline) return;
if (!selectedIssue) return;
const item = selectedIssue;
button.disabled = true;
qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…';
try {
if (selectedIssueOffline) {
await queueOfflineIssueBlocker(item, blocker, !remove);
if (selectedIssue !== item) return;
button.textContent = 'Queued';
qs('#issue-blocker-status').textContent = remove ?
'Blocker removal queued. Today remains blocked until Stackchain confirms delivery.' :
'Blocker addition queued. Today readiness will update after Stackchain confirms delivery.';
return { queued:true };
}
const result = remove ?
await issueController.updateBlocker(selectedIssue, blocker, true) :
await issueController.updateBlocker(selectedIssue, blocker, false);
@@ -1437,8 +1449,8 @@
results.querySelectorAll('[data-blocker-result]').forEach(button => {
button.addEventListener('click', () => {
const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)];
if (blocker) mutateIssueBlocker(blocker, false, button).then(() => {
if (selectedIssueDetail?.dependencies?.some(candidate =>
if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => {
if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate =>
candidate.repository === blocker.repository && candidate.number === blocker.number
)) {
qs('#issue-blocker-manager').hidden = true;
@@ -1474,10 +1486,14 @@
qs('#issue-blocker-search-status').textContent = 'Searching open issues…';
issueBlockerSearchTimer = setTimeout(async () => {
try {
const payload = await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10');
const candidates = selectedIssueOffline ? workSession.items() :
(await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10')).items;
const existing = selectedIssueDetail?.dependencies || [];
const items = (payload.items || []).filter(result =>
const normalizedQuery = query.toLowerCase();
const items = (candidates || []).filter(result =>
result.kind === 'issue' && result.state === 'open' &&
(!selectedIssueOffline || (String(result.repository || '') + '#' + result.number + ' ' + String(result.title || ''))
.toLowerCase().includes(normalizedQuery)) &&
!(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) &&
!existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number)
);
+1
View File
@@ -729,6 +729,7 @@
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/offline-issue-close.js"></script>
<script src="static/offline-issue-blocker.js"></script>
<script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
+21
View File
@@ -0,0 +1,21 @@
function createOfflineIssueBlocker({
enqueueDurably,
onQueued = () => {},
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
}) {
return async function queueOfflineIssueBlocker(item, blocker, present) {
const admission = await enqueueDurably({
kind: 'issue-blocker',
repository: String(item.repository || ''),
number: Number(item.number || 0),
blockerRepository: String(blocker.repository || ''),
blockerNumber: Number(blocker.number || 0),
present: present === true,
operationId: String(createOperationId()).slice(0, 128),
});
onQueued(item, blocker, present === true, admission);
return { admission };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
+1
View File
@@ -22,6 +22,7 @@ const SHELL = [
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/offline-issue-blocker.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',