feat: make authored actions idempotent (#185)
CI / lint (pull_request) Successful in 13s
CI / build-frontend (pull_request) Successful in 4s

This commit is contained in:
timmy committed 2026-08-07 09:55:21 +00:00
1 parent 4da3b198d0
commit 0bc6e01eb5
9 files changed
+389 -30

No files matched your search

+6 -3
View File
@@ -598,7 +598,7 @@ textarea { resize: vertical; min-height: 120px; }
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
return payload;
}
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
const reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
@@ -670,10 +670,13 @@ textarea { resize: vertical; min-height: 120px; }
return payload;
}
async function postNotificationReply(notificationId, body) {
async function postNotificationReply(notificationId, body, operationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/reply', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify({ body }),
});
const payload = await response.json().catch(() => ({}));
+14 -3
View File
@@ -1,4 +1,4 @@
function createIssueSheet({ fetchJson, storage }) {
function createIssueSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
@@ -6,6 +6,7 @@ function createIssueSheet({ fetchJson, storage }) {
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
const operationKey = item => draftKey(item) + ':operation';
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
return {
@@ -24,7 +25,10 @@ function createIssueSheet({ fetchJson, storage }) {
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage?.setItem(draftKey(item), body); }
try {
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
storage?.setItem(draftKey(item), body);
}
catch (_error) { /* The textarea remains the fallback. */ }
},
loadLabelDraft(item) {
@@ -82,13 +86,20 @@ function createIssueSheet({ fetchJson, storage }) {
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(issuePath(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
try { storage?.removeItem(operationKey(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
+17 -3
View File
@@ -286,27 +286,41 @@ function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, o
};
}
function createNotificationReplier({ post, storage, onStatus }) {
function createNotificationReplier({
post, storage, onStatus,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pending = false;
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
const operationKeyFor = item => keyFor(item) + ':operation';
return {
loadDraft(item) {
try { return storage.getItem(keyFor(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage.setItem(keyFor(item), body); }
try {
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
storage.setItem(keyFor(item), body);
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
},
async submit(item, body) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage.getItem(operationKeyFor(item)) || String(createOperationId()).slice(0, 128);
storage.setItem(operationKeyFor(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
const result = await post(item.notification_id, body);
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
try { storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (_error) {
+14 -3
View File
@@ -11,12 +11,13 @@ function mergeEligibility(detail) {
return { allowed: true, reason: 'Ready to merge' };
}
function createPullSheet({ fetchJson, storage }) {
function createPullSheet({ fetchJson, storage, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
const operationKey = item => draftKey(item) + ':operation';
return {
load(item) {
@@ -27,19 +28,29 @@ function createPullSheet({ fetchJson, storage }) {
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage?.setItem(draftKey(item), body); }
try {
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
storage?.setItem(draftKey(item), body);
}
catch (_error) { /* The textarea remains the fallback. */ }
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
let operationId;
try {
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey(item), operationId);
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
commentRequest = fetchJson(pathFor(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
try { storage?.removeItem(operationKey(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
+22 -2
View File
@@ -1,4 +1,8 @@
function createReviewController({ fetchJson }) {
function createReviewController({
fetchJson,
storage,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pendingSubmission = null;
function endpoint(item) {
@@ -16,10 +20,26 @@ function createReviewController({ fetchJson }) {
function submit(item, payload) {
if (pendingSubmission) return pendingSubmission;
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
const fingerprint = JSON.stringify(payload);
let operationId;
try {
const saved = JSON.parse(storage?.getItem(operationKey) || 'null');
operationId = saved?.fingerprint === fingerprint && saved?.operationId
? saved.operationId : String(createOperationId()).slice(0, 128);
storage?.setItem(operationKey, JSON.stringify({ fingerprint, operationId }));
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
pendingSubmission = fetchJson(endpoint(item), {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': operationId,
},
body: JSON.stringify(payload),
}).then(result => {
try { storage?.removeItem(operationKey); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
return result;
}).finally(() => { pendingSubmission = null; });
return pendingSubmission;
}