Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63723e7fa3 | ||
|
|
740e91a615 | ||
|
|
0363f23cef | ||
|
|
2cbeba30fd | ||
|
|
43595de52f | ||
|
|
c451cd0bcb | ||
|
|
ad27db6846 | ||
|
|
791082bae2 | ||
|
|
d3ed0cca0c | ||
|
|
ccb595a081 | ||
|
|
7b1c9a608c | ||
|
|
854d040e88 | ||
|
|
95baa20eeb | ||
|
|
0cf88d7d7a | ||
|
|
969d5567d4 |
No files matched your search
@@ -40,7 +40,10 @@ another worker replays a confirmed result instead of posting duplicate content.
|
||||
five-item Today plan syncs across the operator's devices. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. Delivery or local-admission failure preserves both the draft
|
||||
and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
@@ -269,11 +272,13 @@ repository catalogs, events, raw patches, and complete API responses are exclude
|
||||
A cold offline launch labels the saved time. Cached Today details open in the existing
|
||||
phone sheet, where comments can enter the account-bound durable outbox. For issue and
|
||||
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
|
||||
until reconnection. Cached
|
||||
requested reviews open in the existing review sheet so file progress, notes, summary,
|
||||
decision, and inline-comment drafts remain usable under the saved head SHA. Review
|
||||
submission is never queued: reconnect reloads the current head before enabling submit;
|
||||
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
|
||||
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
|
||||
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
|
||||
**Queue review for reconnect** durably stores that complete SHA-bound review with one
|
||||
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
|
||||
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
|
||||
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
|
||||
queued operation and its matching SHA-scoped draft and progress.
|
||||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
||||
Cards without a saved detail explain that reconnection is required. **Clear offline
|
||||
@@ -285,7 +290,8 @@ issue comments, pull-request comments, and unread-update replies use bounded loc
|
||||
outboxes when connectivity or a retryable server failure prevents delivery. Issue
|
||||
captures and authored messages are also mirrored into account-bound IndexedDB lanes
|
||||
and registered with Background Sync, so a supporting installed browser can deliver
|
||||
new issues, issue comments, pull-request comments, and unread-update replies after
|
||||
new issues, issue comments, pull-request comments, unread-update replies, and completed
|
||||
pull-request reviews after
|
||||
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
||||
an atomic delivery claim with the foreground path, and preserves the original
|
||||
idempotency key. Installed browsers can explicitly enable **Notify me when queued
|
||||
|
||||
+73
-11
@@ -4,7 +4,26 @@ 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']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
body: String(message.body || ''),
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
comments: Array.isArray(message.comments) ? message.comments : [],
|
||||
});
|
||||
}
|
||||
|
||||
function clearConfirmedReviewState(item) {
|
||||
if (item.kind !== 'pull-review') return;
|
||||
try {
|
||||
if (item.draftKey && item.draftFingerprint &&
|
||||
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
|
||||
if (item.progressKey && item.progressFingerprint &&
|
||||
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
|
||||
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
@@ -32,6 +51,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
if (existing) return { ...existing };
|
||||
if (message.kind === 'pull-review') {
|
||||
const queuedReview = items.find(item => item.kind === 'pull-review' &&
|
||||
item.repository === String(message.repository || '') &&
|
||||
item.number === Number(message.number || 0) &&
|
||||
item.expectedHeadSha === String(message.expectedHeadSha || ''));
|
||||
if (queuedReview) {
|
||||
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
|
||||
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
|
||||
}
|
||||
}
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const item = {
|
||||
@@ -45,6 +74,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'pull-review' ? {
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
|
||||
draftKey: String(message.draftKey || ''),
|
||||
progressKey: String(message.progressKey || ''),
|
||||
draftFingerprint: String(message.draftFingerprint || ''),
|
||||
progressFingerprint: String(message.progressFingerprint || ''),
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -95,6 +133,12 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
|
||||
}
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
@@ -114,17 +158,32 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
if (item.kind === 'issue-close') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
});
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
} else {
|
||||
const body = item.kind === 'pull-review' ? {
|
||||
body: item.body,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments,
|
||||
} : { body: item.body };
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!result) return { blocked: true };
|
||||
clearConfirmedReviewState(item);
|
||||
discard(item.id);
|
||||
return { result };
|
||||
} catch (error) {
|
||||
@@ -196,7 +255,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
if (background?.status === 'sent') return [];
|
||||
if (background?.status === 'sent') {
|
||||
clearConfirmedReviewState(item);
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item,
|
||||
status: 'attention',
|
||||
|
||||
@@ -205,6 +205,7 @@ function createBackgroundIssueSync({
|
||||
batch = work => work(), requestTimeoutMs = 15000,
|
||||
}) {
|
||||
let purgeRequested = false;
|
||||
let activePurge = null;
|
||||
let activeFlush = null;
|
||||
const activeRequests = new Set();
|
||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||
@@ -242,6 +243,18 @@ function createBackgroundIssueSync({
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
id: item.id, status, kind: 'message',
|
||||
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
id: item.id, status, kind: 'message',
|
||||
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
@@ -260,6 +273,34 @@ function createBackgroundIssueSync({
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
options: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: item.body,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments || [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return authoredRequest(
|
||||
@@ -306,6 +347,11 @@ function createBackgroundIssueSync({
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await store.complete(item.id, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
@@ -397,15 +443,26 @@ function createBackgroundIssueSync({
|
||||
return activeFlush;
|
||||
}
|
||||
|
||||
async function purge() {
|
||||
function purge() {
|
||||
if (activePurge) return activePurge;
|
||||
purgeRequested = true;
|
||||
activeRequests.forEach(controller => controller.abort());
|
||||
if (activeFlush) await activeFlush.catch(() => {});
|
||||
await store.close?.();
|
||||
activePurge = (async () => {
|
||||
if (activeFlush) await activeFlush.catch(() => {});
|
||||
await store.close?.();
|
||||
})();
|
||||
return activePurge;
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
const pendingPurge = activePurge;
|
||||
if (pendingPurge) await pendingPurge;
|
||||
if (activePurge === pendingPurge) activePurge = null;
|
||||
purgeRequested = false;
|
||||
}
|
||||
|
||||
return {
|
||||
flush, send, purge,
|
||||
flush, send, purge, resume,
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
function createCommentNext({ post, queue, canQueue, accept = () => undefined, complete }) {
|
||||
let inFlight = null;
|
||||
|
||||
function submit(item, body, operationId = '') {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
let comment;
|
||||
try {
|
||||
comment = await post(item, body);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
const admission = await queue({
|
||||
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
body,
|
||||
operationId: typeof operationId === 'function' ? operationId() : operationId,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Comment was not saved for delivery.');
|
||||
}
|
||||
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
background: Boolean(admission.background),
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
}
|
||||
accept(item, { delivery: 'posted', comment });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: 'posted',
|
||||
comment,
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
|
||||
@@ -226,6 +226,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.comment-actions button { min-height:44px; width:100%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
@@ -251,7 +253,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
|
||||
+185
-6
@@ -120,6 +120,7 @@
|
||||
let selectedUpdate = null;
|
||||
let updateTrigger = null;
|
||||
let selectedIssue = null;
|
||||
let selectedIssueOffline = false;
|
||||
let selectedIssueDetail = null;
|
||||
let issueConversation = null;
|
||||
let issueTrigger = null;
|
||||
@@ -710,6 +711,9 @@
|
||||
document.querySelectorAll('[data-work-session-next]').forEach(button => {
|
||||
button.textContent = state.can_next ? 'Next work item' : 'Finish session';
|
||||
});
|
||||
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
|
||||
button.hidden = !workSession.checkpointed();
|
||||
});
|
||||
},
|
||||
onFinish: () => {
|
||||
closeOpenWorkSheets();
|
||||
@@ -764,6 +768,67 @@
|
||||
warm: warmTodayOffline,
|
||||
});
|
||||
|
||||
const selectedSessionItem = kind => ({
|
||||
issue: selectedIssue,
|
||||
pull: selectedPull,
|
||||
review: selectedReview,
|
||||
update: selectedUpdate,
|
||||
})[kind] || null;
|
||||
const completeTodayItem = createTodayCompletion({
|
||||
todayWork,
|
||||
todaySync,
|
||||
workSession,
|
||||
refresh: () => refreshMyWorkView({ reconcileSession:false }),
|
||||
warm: warmTodayOffline,
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
function setCommentNextVisibility(kind) {
|
||||
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||||
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
|
||||
}
|
||||
const issueCommentNext = createCommentNext({
|
||||
post: async (item, body) => {
|
||||
const comment = await issueController.comment(item, body);
|
||||
if (selectedIssue === item && issueConversation) renderIssueConversation(issueConversation.append(comment));
|
||||
return comment;
|
||||
},
|
||||
queue: message => authoredOutbox.enqueueDurably(message),
|
||||
canQueue: canQueueMessage,
|
||||
accept: item => {
|
||||
issueController.saveDraft(item, '');
|
||||
if (selectedIssue === item) qs('#issue-comment').value = '';
|
||||
},
|
||||
complete: item => completeTodayItem(item, {
|
||||
successMessage: 'Comment saved. Next Today item opened.',
|
||||
failureMessage: 'Comment saved, but Today still needs completion.',
|
||||
}),
|
||||
});
|
||||
const pullCommentNext = createCommentNext({
|
||||
post: async (item, body) => {
|
||||
const comment = await pullController.comment(item, body);
|
||||
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
|
||||
return comment;
|
||||
},
|
||||
queue: message => authoredOutbox.enqueueDurably(message),
|
||||
canQueue: canQueueMessage,
|
||||
accept: item => {
|
||||
pullController.saveDraft(item, '');
|
||||
if (selectedPull === item) qs('#pull-comment').value = '';
|
||||
},
|
||||
complete: item => completeTodayItem(item, {
|
||||
successMessage: 'Comment saved. Next Today item opened.',
|
||||
failureMessage: 'Comment saved, but Today still needs completion.',
|
||||
}),
|
||||
});
|
||||
const closeOfflineIssue = createOfflineIssueClose({
|
||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||
completeToday: (item, options) => completeTodayItem(item, options),
|
||||
});
|
||||
|
||||
function reviewingActiveTodayItem() {
|
||||
return workSession.checkpointed();
|
||||
}
|
||||
|
||||
function acceptClaimedIssue(confirmed) {
|
||||
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
|
||||
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
|
||||
@@ -1038,7 +1103,7 @@
|
||||
|
||||
function setOfflineDetailControls(kind) {
|
||||
const selectors = kind === 'issue' ? [
|
||||
'#edit-issue-content', '#close-issue', '#release-issue', '#load-issue-handoff',
|
||||
'#edit-issue-content', '#release-issue', '#load-issue-handoff',
|
||||
'#issue-handoff-recipient', '#confirm-issue-handoff', '#issue-due-date',
|
||||
'#save-issue-labels', '#save-issue-due-date', '#clear-issue-due-date',
|
||||
'#issue-milestone', '#save-issue-milestone', '#load-older-issue-comments',
|
||||
@@ -1227,6 +1292,8 @@
|
||||
list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => {
|
||||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const isUnfiled = item.kind === 'unfiled-issue';
|
||||
const reviewOutbox = item.outbox_kind === 'pull-review';
|
||||
const closureOutbox = item.outbox_kind === 'issue-close';
|
||||
const sendLabel = item.delivery_state === 'uncertain' ? 'Verified not posted — retry' : 'Send now';
|
||||
const outboxActions = item.quarantined ?
|
||||
'<button class="draft-copy" data-draft-index="' + index + '" type="button">Copy content</button>' +
|
||||
@@ -1238,10 +1305,18 @@
|
||||
'<button class="draft-edit" data-draft-index="' + index + '" type="button">Edit</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
item.kind === 'authored-outbox' ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open message</button>' +
|
||||
reviewOutbox && item.status === 'attention' ?
|
||||
'<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-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||||
item.kind === 'authored-outbox' && !reviewOutbox ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
||||
(closureOutbox ? 'Open issue' : 'Open message') + '</button>' +
|
||||
'<button class="draft-send" data-draft-index="' + index + '" type="button">' + sendLabel + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard</button>' :
|
||||
reviewOutbox ?
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">Open review</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard queued review</button>' :
|
||||
'<button class="draft-resume" data-draft-index="' + index + '" type="button">' +
|
||||
(isUnfiled ? 'Choose repository' : 'Resume draft') + '</button>' +
|
||||
'<button class="draft-discard" data-draft-index="' + index + '" type="button">Discard draft</button>';
|
||||
@@ -1656,6 +1731,7 @@
|
||||
qs('#issue-planning').inert = false;
|
||||
qs('#issue-handoff').inert = false;
|
||||
selectedIssue = item;
|
||||
selectedIssueOffline = Boolean(offlineDetail);
|
||||
selectedIssueDetail = null;
|
||||
issueConversation = null;
|
||||
issueTrigger = trigger;
|
||||
@@ -1694,11 +1770,14 @@
|
||||
qs('#retry-issue-load').hidden = true;
|
||||
qs('#open-issue-gitea').href = item.url || '#';
|
||||
qs('#send-issue-comment').disabled = false;
|
||||
setCommentNextVisibility('issue');
|
||||
qs('#edit-issue-content').disabled = true;
|
||||
qs('#issue-edit-form').hidden = true;
|
||||
qs('#issue-edit-status').textContent = '';
|
||||
qs('#close-issue').disabled = false;
|
||||
qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';
|
||||
qs('#close-issue').textContent = offlineDetail ?
|
||||
(workSession.active() ? 'Queue close & next' : 'Queue issue closure') :
|
||||
(workSession.active() ? 'Close & next' : 'Close issue');
|
||||
qs('#close-issue-sheet').focus();
|
||||
try {
|
||||
const detail = offlineDetail || await issueController.load(item);
|
||||
@@ -1747,6 +1826,7 @@
|
||||
mobileComposerViewport.close(qs('#issue-sheet .issue-sheet-panel'));
|
||||
qs('#issue-sheet').classList.remove('open');
|
||||
selectedIssue = null;
|
||||
selectedIssueOffline = false;
|
||||
selectedIssueDetail = null;
|
||||
issueConversation = null;
|
||||
if (issueTrigger?.isConnected) issueTrigger.focus();
|
||||
@@ -1853,6 +1933,7 @@
|
||||
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
|
||||
qs('#retry-pull-load').hidden = true;
|
||||
qs('#open-pull-gitea').href = item.url || '#';
|
||||
setCommentNextVisibility('pull');
|
||||
qs('#close-pull-sheet').focus();
|
||||
try {
|
||||
const detail = offlineDetail || await pullController.load(item);
|
||||
@@ -2355,7 +2436,8 @@
|
||||
qs('#review-submit-status').textContent = '';
|
||||
qs('#continue-review-to-merge').hidden = true;
|
||||
qs('#submit-review').disabled = true;
|
||||
qs('#submit-review').textContent = offlineReview ? 'Reconnect to validate & submit' : 'Submit review';
|
||||
qs('#submit-review').textContent = offlineReview && reviewingActiveTodayItem() ? 'Queue review & next' :
|
||||
(offlineReview ? 'Queue review for reconnect' : 'Submit review');
|
||||
closeInlineComposer();
|
||||
draft = null;
|
||||
reviewFiles = [];
|
||||
@@ -2422,7 +2504,7 @@
|
||||
qs('#review-sheet-status').textContent = offlineReview ?
|
||||
'Offline review · saved ' + fmt(detail.saved_at) + ' · draft feedback stays on this device.' :
|
||||
'Ready to review · by ' + (detail.author || 'unknown author');
|
||||
qs('#submit-review').disabled = offlineReview;
|
||||
qs('#submit-review').disabled = false;
|
||||
} catch (error) {
|
||||
if (selectedReview !== item) return;
|
||||
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
|
||||
@@ -3309,6 +3391,43 @@
|
||||
qs('#issue-milestone').disabled = false;
|
||||
}
|
||||
});
|
||||
async function submitCommentAndNext(kind) {
|
||||
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||||
if (!item || !workSession.checkpointed(item)) return;
|
||||
const textarea = qs('#' + kind + '-comment');
|
||||
const status = qs('#' + kind + '-comment-status');
|
||||
const body = textarea.value.trim();
|
||||
if (!body) {
|
||||
status.textContent = 'Write a comment before posting.';
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
const postButton = qs('#send-' + kind + '-comment');
|
||||
const nextButton = qs('#send-' + kind + '-comment-next');
|
||||
const controller = kind === 'issue' ? issueCommentNext : pullCommentNext;
|
||||
const operationId = () => localStorage.getItem('stackchain.' + kind + '-comment.v1:' +
|
||||
item.repository + '#' + item.number + ':operation');
|
||||
postButton.disabled = true;
|
||||
nextButton.disabled = true;
|
||||
status.textContent = 'Posting comment and opening next…';
|
||||
try {
|
||||
const result = await controller.submit(item, body, operationId);
|
||||
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
|
||||
if (!stillOpen) return;
|
||||
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
|
||||
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
|
||||
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
|
||||
else status.textContent = 'Saved for next launch; background delivery unavailable.';
|
||||
} catch (error) {
|
||||
status.textContent = error.message + ' Your draft and Today position are safe; retry.';
|
||||
textarea.focus();
|
||||
} finally {
|
||||
postButton.disabled = false;
|
||||
nextButton.disabled = false;
|
||||
}
|
||||
}
|
||||
qs('#send-issue-comment-next').addEventListener('click', () => submitCommentAndNext('issue'));
|
||||
qs('#send-pull-comment-next').addEventListener('click', () => submitCommentAndNext('pull'));
|
||||
qs('#send-issue-comment').addEventListener('click', async () => {
|
||||
if (!selectedIssue) return;
|
||||
const body = qs('#issue-comment').value.trim();
|
||||
@@ -3427,6 +3546,26 @@
|
||||
const closing = selectedIssue;
|
||||
const button = qs('#close-issue');
|
||||
button.disabled = true;
|
||||
if (selectedIssueOffline) {
|
||||
qs('#issue-sheet-status').textContent = 'Saving issue closure for background delivery…';
|
||||
try {
|
||||
const outcome = await closeOfflineIssue(closing);
|
||||
closeIssueSheet();
|
||||
refreshMyWorkView({ reconcileSession:false });
|
||||
if (!outcome.advanced) {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure queued, but Today still needs completion.';
|
||||
} else if (outcome.admission.background) {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure queued for reconnect. Next Today item opened.';
|
||||
} else {
|
||||
qs('#my-work-action-status').textContent = 'Issue closure saved for next launch. Next Today item opened.';
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in Today; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
qs('#issue-sheet-status').textContent = 'Closing issue…';
|
||||
try {
|
||||
await issueController.close(selectedIssue);
|
||||
@@ -3633,6 +3772,41 @@
|
||||
)) return;
|
||||
const button = qs('#submit-review');
|
||||
button.disabled = true;
|
||||
if (offlineReview) {
|
||||
const queuedTodayReview = reviewingActiveTodayItem();
|
||||
qs('#review-submit-status').textContent = 'Queueing review safely…';
|
||||
try {
|
||||
await authoredOutbox.enqueueDurably({
|
||||
kind: 'pull-review',
|
||||
repository: selectedReview.repository,
|
||||
number: selectedReview.number,
|
||||
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
||||
decision: snapshot.decision,
|
||||
expectedHeadSha: selectedReviewHead,
|
||||
comments: snapshot.comments,
|
||||
draftKey: draft.storageKey,
|
||||
progressKey: progress?.storageKey || '',
|
||||
draftFingerprint: localStorage.getItem(draft.storageKey) || '',
|
||||
progressFingerprint: localStorage.getItem(progress?.storageKey) || '',
|
||||
});
|
||||
if (queuedTodayReview) {
|
||||
const advanced = completeTodayItem(selectedReview, {
|
||||
successMessage: 'Review queued. Next Today item opened.',
|
||||
failureMessage: 'Review queued, but Today still needs completion.',
|
||||
});
|
||||
if (!advanced) {
|
||||
qs('#review-submit-status').textContent = 'Review queued, but Today still needs completion.';
|
||||
}
|
||||
} else {
|
||||
qs('#review-submit-status').textContent = 'Review queued · it will submit after reconnect.';
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#review-submit-status').textContent = error.message + ' Your draft is safe; retry when ready.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
qs('#review-submit-status').textContent = 'Submitting review…';
|
||||
try {
|
||||
const item = selectedReview;
|
||||
@@ -3890,6 +4064,11 @@
|
||||
document.querySelectorAll('[data-work-session-next]').forEach(button =>
|
||||
button.addEventListener('click', () => workSession.next())
|
||||
);
|
||||
document.querySelectorAll('[data-work-session-complete]').forEach(button =>
|
||||
button.addEventListener('click', () =>
|
||||
completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))
|
||||
)
|
||||
);
|
||||
qs('#load-more-notifications').addEventListener('click', () =>
|
||||
notificationPager.loadMore(lastNotifications)
|
||||
);
|
||||
|
||||
+15
-3
@@ -139,22 +139,34 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply';
|
||||
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
const isReview = item.kind === 'pull-review';
|
||||
const isClosure = item.kind === 'issue-close';
|
||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
|
||||
String(comment.path || 'File') + ' · ' +
|
||||
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
|
||||
': ' + String(comment.body || '')
|
||||
).filter(Boolean) : [];
|
||||
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
|
||||
.filter(Boolean).join('\n\n');
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued message'),
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: item.body,
|
||||
copy_text: copyText,
|
||||
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
|
||||
+15
-2
@@ -269,7 +269,10 @@
|
||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||
<h2 id="issue-comment-title">Add comment</h2>
|
||||
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
<div class="comment-actions">
|
||||
<button id="send-issue-comment" type="button">Post comment</button>
|
||||
<button id="send-issue-comment-next" type="button" hidden>Comment & next</button>
|
||||
</div>
|
||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
@@ -333,6 +336,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-complete="issue" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -448,6 +452,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-complete="update" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -468,7 +473,10 @@
|
||||
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
|
||||
<h2 id="pull-comment-title">Add comment</h2>
|
||||
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||
<button id="send-pull-comment" type="button">Post comment</button>
|
||||
<div class="comment-actions">
|
||||
<button id="send-pull-comment" type="button">Post comment</button>
|
||||
<button id="send-pull-comment-next" type="button" hidden>Comment & next</button>
|
||||
</div>
|
||||
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="pull-review" id="pull-review">
|
||||
@@ -488,6 +496,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-complete="pull" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -552,6 +561,7 @@
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
<button type="button" data-work-session-complete="review" hidden>Done for Today & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -590,12 +600,15 @@
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<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/notification-read-outbox.js"></script>
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
|
||||
+1
-1
@@ -563,7 +563,7 @@ function createWorkSession({
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
checkpointed: () => running && durable,
|
||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
reopen() {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
|
||||
return async function closeOfflineIssue(item) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-close',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
body: '',
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
const advanced = Boolean(completeToday(item, {
|
||||
successMessage: 'Issue closure queued. Next Today item opened.',
|
||||
failureMessage: 'Issue closure queued, but Today still needs completion.',
|
||||
}));
|
||||
return { admission, advanced };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;
|
||||
@@ -169,7 +169,7 @@ function createProgress({ storage, repository, number, headSha, files }) {
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, markReviewed, clear };
|
||||
return { snapshot, markReviewed, clear, storageKey: key };
|
||||
}
|
||||
|
||||
function createDraft({ storage, repository, number, headSha, files }) {
|
||||
@@ -267,7 +267,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
|
||||
return {
|
||||
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
|
||||
storageKey: key,
|
||||
};
|
||||
}
|
||||
|
||||
function formatFeedback(draft, files) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v67';
|
||||
const CACHE = 'stackchain-dashboard-shell-v72';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -21,12 +21,15 @@ const SHELL = [
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
@@ -216,7 +219,10 @@ self.addEventListener('sync', event => {
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-session-lease') {
|
||||
event.waitUntil(storeOfflineLease(event.data.expiresAt));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce }) {
|
||||
return function completeTodayItem(item, options = {}) {
|
||||
if (!item || !todayWork.remove(item)) {
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
workSession.complete();
|
||||
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
|
||||
+4
-2
@@ -25,7 +25,7 @@ def _quality(accept_encoding: str, coding: str) -> float:
|
||||
class NegotiatedGZipMiddleware:
|
||||
"""Compress substantial responses only when the client permits gzip."""
|
||||
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
|
||||
self.app = app
|
||||
self.minimum_size = minimum_size
|
||||
self.compresslevel = compresslevel
|
||||
@@ -36,7 +36,9 @@ class NegotiatedGZipMiddleware:
|
||||
return
|
||||
|
||||
accepted = Headers(scope=scope).get("Accept-Encoding", "")
|
||||
if _quality(accepted, "gzip") > 0:
|
||||
path = scope.get("path", "").rsplit("/", 1)[-1]
|
||||
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
|
||||
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
|
||||
responder: ASGIApp = GZipResponder(
|
||||
self.app, self.minimum_size, compresslevel=self.compresslevel
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@@ -16,6 +17,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
class FrontendBuild:
|
||||
dashboard_html: str
|
||||
runtime_bytes: bytes
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
page_sources: tuple[str, ...]
|
||||
@@ -38,6 +40,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
raise ValueError("dashboard entry document has no page scripts")
|
||||
|
||||
runtime = _bundle(frontend_dir, sources)
|
||||
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
|
||||
digest = hashlib.sha256(runtime).hexdigest()[:16]
|
||||
runtime_name = f"runtime-{digest}.js"
|
||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||
@@ -63,6 +66,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
return FrontendBuild(
|
||||
dashboard_html=dashboard_html,
|
||||
runtime_bytes=runtime,
|
||||
runtime_gzip_bytes=runtime_gzip,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
page_sources=sources,
|
||||
|
||||
+14
-4
@@ -1,9 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.compression import _quality
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
@@ -51,11 +52,20 @@ async def service_worker() -> Response:
|
||||
|
||||
|
||||
@router.get("/runtime-{digest}.js")
|
||||
async def runtime_bundle(digest: str) -> Response:
|
||||
async def runtime_bundle(digest: str, request: Request) -> Response:
|
||||
if digest != FRONTEND_BUILD.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Runtime revision not found")
|
||||
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
|
||||
return Response(
|
||||
FRONTEND_BUILD.runtime_bytes,
|
||||
(
|
||||
FRONTEND_BUILD.runtime_gzip_bytes
|
||||
if accepts_gzip
|
||||
else FRONTEND_BUILD.runtime_bytes
|
||||
),
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Vary": "Accept-Encoding",
|
||||
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
|
||||
},
|
||||
)
|
||||
@@ -77,6 +77,85 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
|
||||
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, options}}); return {{id:44}}; }},
|
||||
}});
|
||||
let queued;
|
||||
try {{
|
||||
queued = outbox.enqueue({{
|
||||
kind:'pull-review', repository:'stackchain/web', number:8, operationId:'review-op',
|
||||
body:'Looks good', decision:'approve', expectedHeadSha:'abc123',
|
||||
comments:[{{path:'app.js',body:'Nice',new_position:4}}],
|
||||
draftKey:'stackchain.review-draft.v1:stackchain/web#8@abc123',
|
||||
progressKey:'stackchain.review-progress.v1:stackchain/web#8@abc123',
|
||||
draftFingerprint:'draft', progressFingerprint:'progress',
|
||||
}});
|
||||
}} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{error:error.message}}));
|
||||
}}
|
||||
if (queued) {{
|
||||
storage.setItem(queued.draftKey, 'draft'); storage.setItem(queued.progressKey, 'progress');
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
queued, calls:calls.map(call=>({{url:call.url,key:call.options.headers['Idempotency-Key'],body:JSON.parse(call.options.body)}})),
|
||||
result, remaining:outbox.list(), draft:storage.getItem(queued.draftKey), progress:storage.getItem(queued.progressKey),
|
||||
}})));
|
||||
}}
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "error" not in output, output.get("error")
|
||||
|
||||
assert output["queued"]["kind"] == "pull-review"
|
||||
assert output["queued"]["comments"] == [{"path": "app.js", "body": "Nice", "new_position": 4}]
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/web/pulls/8/review",
|
||||
"key": "review-op",
|
||||
"body": {
|
||||
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
|
||||
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
|
||||
},
|
||||
}]
|
||||
assert output["remaining"] == []
|
||||
assert output["draft"] is None
|
||||
assert output["progress"] is None
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
|
||||
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,options}}); return {{number:27,state:'closed'}}; }},
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
|
||||
}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
queued,calls,result,remaining:outbox.list()
|
||||
}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["kind"] == "issue-close"
|
||||
assert output["calls"] == [{
|
||||
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
|
||||
"options": {
|
||||
"method": "PATCH",
|
||||
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
|
||||
},
|
||||
}]
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -183,6 +262,52 @@ process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_deduplicates_pull_review_for_same_head_after_reload():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();let sequence=0;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const make=()=>createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-' + (++sequence)}});
|
||||
const first=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
|
||||
const second=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
|
||||
process.stdout.write(JSON.stringify({{first,second,items:make().list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == output["second"]
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
def test_authored_outbox_rejects_changed_feedback_while_same_head_is_queued():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
|
||||
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'First'}});
|
||||
let error='';try{{outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'request_changes',body:'Changed'}});}}catch(e){{error=e.message;}}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["error"] == "A review for this saved head is already queued. Open Drafts to inspect or discard it first."
|
||||
assert len(output["items"]) == 1
|
||||
|
||||
|
||||
def test_confirmed_review_keeps_draft_edits_made_after_it_was_queued():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const draftKey='draft-key';const progressKey='progress-key';storage.setItem(draftKey,'old-draft');storage.setItem(progressKey,'old-progress');
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',fetchJson:async()=>({{id:1}})}});
|
||||
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',body:'Queued',draftKey,progressKey,draftFingerprint:'old-draft',progressFingerprint:'old-progress'}});
|
||||
storage.setItem(draftKey,'newer-draft');storage.setItem(progressKey,'newer-progress');
|
||||
outbox.flush('timmy').then(()=>process.stdout.write(JSON.stringify({{draft:storage.getItem(draftKey),progress:storage.getItem(progressKey)}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"draft": "newer-draft", "progress": "newer-progress"}
|
||||
|
||||
|
||||
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -203,6 +203,69 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
assert output["result"]["confirmed"] == [{"id": 42}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_pull_review_with_stable_identity_and_full_payload():
|
||||
authored = {
|
||||
"id": "review-op", "operationId": "review-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "pull-review", "repository": "stackchain/web", "number": 8,
|
||||
"body": "Looks good", "decision": "approve", "expectedHeadSha": "abc123",
|
||||
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
|
||||
}
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let queued = {json.dumps(authored)}; const calls=[];
|
||||
const store = {{
|
||||
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/web/pulls/8/review"
|
||||
assert mutation["options"]["headers"]["Idempotency-Key"] == "review-op"
|
||||
assert json.loads(mutation["options"]["body"]) == {
|
||||
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
|
||||
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"id": 45}]
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "review-op", "status": "confirmed", "kind": "message",
|
||||
"route": "#/my-work/review/stackchain/web/8",
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
|
||||
authored = {
|
||||
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
|
||||
}
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let queued = {json.dumps(authored)}; const calls=[];
|
||||
const store = {{
|
||||
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
|
||||
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutation = output["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
|
||||
assert mutation["options"]["method"] == "PATCH"
|
||||
assert mutation["options"]["headers"] == {
|
||||
"Accept": "application/json", "Idempotency-Key": "close-op"
|
||||
}
|
||||
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["result"]["receipts"] == [{
|
||||
"id": "close-op", "status": "confirmed", "kind": "message",
|
||||
"route": "#/my-work/issue/stackchain/dashboard/27",
|
||||
}]
|
||||
|
||||
|
||||
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -778,6 +841,51 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
assert "error" not in output
|
||||
|
||||
|
||||
def test_resume_after_purge_reopens_background_delivery_with_fresh_identity():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const state={{closed:0,identityLookups:0,mutations:0,keys:[]}};
|
||||
let queued={{
|
||||
id:'capture-after-login',ownerLogin:'timmy',status:'queued',repository:'stackchain/dashboard',
|
||||
title:'Recovered capture',body:'Evidence',labelIds:[],operationId:'op-after-login',
|
||||
}};
|
||||
const store={{
|
||||
close:async()=>{{state.closed+=1;}},
|
||||
claimBatch:async login=>{{
|
||||
if(queued?.ownerLogin!==login)return [];
|
||||
const claimed=queued;queued=null;return [claimed];
|
||||
}},
|
||||
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity'){{state.identityLookups+=1;return {{login:'timmy'}};}}
|
||||
state.mutations+=1;state.keys.push(options.headers['Idempotency-Key']);
|
||||
return {{repository:'stackchain/dashboard',number:425,title:'Recovered capture'}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
await sync.purge();
|
||||
const blocked=await sync.flush();
|
||||
await sync.resume();
|
||||
const delivered=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,blocked,delivered}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {
|
||||
"closed": 1,
|
||||
"identityLookups": 1,
|
||||
"mutations": 1,
|
||||
"keys": ["op-after-login"],
|
||||
}
|
||||
assert output["blocked"]["login"] == ""
|
||||
assert output["delivered"]["login"] == "timmy"
|
||||
assert output["delivered"]["confirmed"] == [
|
||||
{"repository": "stackchain/dashboard", "number": 425, "title": "Recovered capture"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
COMMENT_NEXT = Path(__file__).parents[1] / "frontend" / "comment-next.js"
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def test_comment_and_next_posts_then_completes_current_today_item_once():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls = [];
|
||||
let finishPost;
|
||||
const controller = createCommentNext({{
|
||||
post: (item, body) => {{ calls.push(`post:${{item.number}}:${{body}}`); return new Promise(resolve => {{ finishPost = resolve; }}); }},
|
||||
queue: () => {{ throw new Error('must not queue'); }},
|
||||
canQueue: () => false,
|
||||
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||
}});
|
||||
const item = {{kind:'issue', repository:'stackchain/dashboard', number:429}};
|
||||
const first = controller.submit(item, 'Handoff ready');
|
||||
const second = controller.submit(item, 'Handoff ready');
|
||||
if (first !== second) throw new Error('submission was not single-flight');
|
||||
finishPost({{id:7, body:'Handoff ready'}});
|
||||
(async () => {{
|
||||
const result = await first;
|
||||
process.stdout.write(JSON.stringify({{result, calls, busy:controller.busy()}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"result": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"comment": {"id": 7, "body": "Handoff ready"},
|
||||
"completed": True,
|
||||
},
|
||||
"calls": ["post:429:Handoff ready", "complete:429"],
|
||||
"busy": False,
|
||||
}
|
||||
|
||||
|
||||
def test_comment_and_next_advances_after_durable_offline_admission():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls = [];
|
||||
const retryable = new Error('offline'); retryable.status = 503;
|
||||
const controller = createCommentNext({{
|
||||
post: () => Promise.reject(retryable),
|
||||
canQueue: error => error.status === 503,
|
||||
queue: message => {{ calls.push(message); return Promise.resolve({{durable:true, background:false}}); }},
|
||||
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.submit({{kind:'pull', repository:'stackchain/dashboard', number:12}}, 'Please review', 'op-12');
|
||||
process.stdout.write(JSON.stringify({{result, calls}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"result": {
|
||||
"accepted": True,
|
||||
"delivery": "saved",
|
||||
"background": False,
|
||||
"completed": True,
|
||||
},
|
||||
"calls": [
|
||||
{
|
||||
"kind": "pull-comment",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 12,
|
||||
"body": "Please review",
|
||||
"operationId": "op-12",
|
||||
},
|
||||
"complete:12",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
let operationId = '';
|
||||
let queued;
|
||||
const controller = createCommentNext({{
|
||||
post: () => {{ operationId = 'post-attempt-id'; return Promise.reject(Object.assign(new Error('timeout'), {{status:503}})); }},
|
||||
canQueue: () => true,
|
||||
queue: message => {{ queued = message; return Promise.resolve({{durable:true}}); }},
|
||||
complete: () => true,
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await controller.submit(
|
||||
{{kind:'issue', repository:'r', number:3}}, 'Status', () => operationId
|
||||
);
|
||||
process.stdout.write(JSON.stringify({{result, queued}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
output = json.loads(run_node(script))
|
||||
assert output["result"]["accepted"] is True
|
||||
assert output["result"]["delivery"] == "saved"
|
||||
assert output["queued"]["operationId"] == "post-attempt-id"
|
||||
|
||||
|
||||
def test_comment_and_next_clears_the_completed_draft_before_opening_next_item():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls = [];
|
||||
const controller = createCommentNext({{
|
||||
post: () => Promise.resolve({{id:1}}), queue: () => null, canQueue: () => false,
|
||||
accept: item => calls.push(`clear:${{item.number}}`),
|
||||
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit({{kind:'issue',repository:'r',number:8}}, 'Done');
|
||||
process.stdout.write(JSON.stringify(calls));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == ["clear:8", "complete:8"]
|
||||
|
||||
|
||||
def test_comment_and_next_keeps_today_position_when_delivery_or_completion_fails():
|
||||
script = f"""
|
||||
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||
const failures = [];
|
||||
const permanent = createCommentNext({{
|
||||
post: () => Promise.reject(Object.assign(new Error('forbidden'), {{status:403}})),
|
||||
canQueue: () => false,
|
||||
queue: () => Promise.resolve({{durable:true}}),
|
||||
complete: () => {{ failures.push('advanced'); return true; }},
|
||||
}});
|
||||
const notDurable = createCommentNext({{
|
||||
post: () => Promise.reject(Object.assign(new Error('offline'), {{status:503}})),
|
||||
canQueue: () => true,
|
||||
queue: () => Promise.resolve({{durable:false, background:false}}),
|
||||
complete: () => {{ failures.push('advanced'); return true; }},
|
||||
}});
|
||||
const cannotComplete = createCommentNext({{
|
||||
post: () => Promise.resolve({{id:9}}), canQueue: () => false, queue: () => null,
|
||||
complete: () => false,
|
||||
}});
|
||||
(async () => {{
|
||||
for (const [name, controller] of [['permanent', permanent], ['notDurable', notDurable]]) {{
|
||||
try {{ await controller.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); }}
|
||||
catch (error) {{ failures.push(`${{name}}:${{error.message}}`); }}
|
||||
}}
|
||||
const partial = await cannotComplete.submit({{kind:'issue',repository:'r',number:1}}, 'draft');
|
||||
process.stdout.write(JSON.stringify({{failures, partial}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"failures": ["permanent:forbidden", "notDurable:Comment was not saved for delivery."],
|
||||
"partial": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"comment": {"id": 9},
|
||||
"completed": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_today_checkpoint_only_matches_the_current_session_item():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'issue', repository:'r', number:1}},
|
||||
{{kind:'pull', repository:'r', number:2}},
|
||||
];
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all', checkpointEnabled: () => true,
|
||||
checkpoint: {{save:()=>undefined, clear:()=>undefined, read:()=>null}},
|
||||
onOpen:()=>undefined, onProgress:()=>undefined, onFinish:()=>undefined,
|
||||
}});
|
||||
session.start();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
current: session.checkpointed(items[0]),
|
||||
other: session.checkpointed(items[1]),
|
||||
generic: session.checkpointed(),
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"current": True,
|
||||
"other": False,
|
||||
"generic": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-issue-comment-next"' in html
|
||||
assert 'id="send-pull-comment-next"' in html
|
||||
assert html.count('>Comment & next</button>') == 2
|
||||
assert "setCommentNextVisibility('issue')" in html
|
||||
assert "setCommentNextVisibility('pull')" in html
|
||||
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.comment-actions button { min-height:44px;' in html
|
||||
@@ -121,6 +121,48 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
|
||||
decision:'approve',expectedHeadSha:'abc',comments:[{{path:'app.js',body:'Nice',new_position:4}}],ownerLogin:'timmy',status:'queued',queuedAt:200
|
||||
}}]}})]]);
|
||||
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 item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["label"] == "Queued review"
|
||||
assert output["outbox_kind"] == "pull-review"
|
||||
assert output["title"] == "stackchain/web#8"
|
||||
assert output["route"] == {"kind": "review", "repository": "stackchain/web", "number": 8}
|
||||
assert output["copy_text"] == "Looks good\n\nInline feedback\napp.js · new line 4: Nice"
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
|
||||
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
|
||||
ownerLogin:'timmy',status:'attention',error:'Closure rejected',queuedAt:200
|
||||
}}]}})]]);
|
||||
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 item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["label"] == "Issue closure needs attention"
|
||||
assert output["outbox_kind"] == "issue-close"
|
||||
assert output["title"] == "stackchain/dashboard#27"
|
||||
assert output["preview"] == "Closure rejected"
|
||||
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
@@ -197,6 +239,8 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
|
||||
assert "closureOutbox ? 'Open issue' : 'Open message'" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from src import main
|
||||
from src.frontend_bundle import build_frontend
|
||||
@@ -21,6 +22,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
|
||||
assert first.runtime_name == second.runtime_name
|
||||
assert first.runtime_bytes == second.runtime_bytes
|
||||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
@@ -52,11 +55,46 @@ async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
assert int(response.headers["content-length"]) <= 100 * 1024
|
||||
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_gzip_bytes)
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_route_returns_the_precomputed_gzip_representation():
|
||||
build = build_frontend(FRONTEND)
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"gzip")]}
|
||||
)
|
||||
|
||||
response = await runtime_bundle(build.runtime_digest, request)
|
||||
|
||||
assert response.body == build.runtime_gzip_bytes
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["content-length"] == str(len(build.runtime_gzip_bytes))
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_requests_bypass_dynamic_gzip_compression(monkeypatch):
|
||||
class UnexpectedDynamicCompression:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError("immutable runtime entered dynamic compression")
|
||||
|
||||
monkeypatch.setattr("src.compression.GZipResponder", UnexpectedDynamicCompression)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": "gzip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
|
||||
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
|
||||
@@ -108,9 +146,13 @@ def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules()
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
|
||||
build = build_frontend(FRONTEND)
|
||||
request = Request(
|
||||
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
|
||||
"headers": [(b"accept-encoding", b"identity")]}
|
||||
)
|
||||
|
||||
html = await dashboard()
|
||||
bundle = await runtime_bundle(build.runtime_digest)
|
||||
bundle = await runtime_bundle(build.runtime_digest, request)
|
||||
worker = await service_worker()
|
||||
|
||||
assert build.runtime_name in html
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v67" in worker
|
||||
assert "stackchain-dashboard-shell-v72" in worker
|
||||
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v67" in worker
|
||||
assert "stackchain-dashboard-shell-v72" in worker
|
||||
+13
-1
@@ -1672,7 +1672,8 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
|
||||
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert "qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';" in html
|
||||
assert "workSession.active() ? 'Close & next' : 'Close issue'" in html
|
||||
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
|
||||
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-pull-sheet').addEventListener", 1
|
||||
)[0]
|
||||
@@ -4205,6 +4206,17 @@ async def test_review_requests_open_an_accessible_mobile_detail_sheet():
|
||||
assert '.review-diff' in html and 'overflow-x:auto' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
|
||||
html = await dashboard()
|
||||
|
||||
assert "const reviewOutbox = item.outbox_kind === 'pull-review';" in html
|
||||
assert "reviewOutbox && item.status === 'attention'" in html
|
||||
assert ">Open current review</button>" in html
|
||||
assert ">Copy feedback</button>" in html
|
||||
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
OFFLINE_CLOSE = Path(__file__).parents[1] / "frontend" / "offline-issue-close.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_offline_issue_close_waits_for_durable_admission_before_advancing_today():
|
||||
script = f"""
|
||||
const createOfflineIssueClose = require({json.dumps(str(OFFLINE_CLOSE))});
|
||||
const events=[]; let release;
|
||||
const gate=new Promise(resolve=>release=resolve);
|
||||
const close=createOfflineIssueClose({{
|
||||
enqueueDurably:async message=>{{events.push('admit');await gate;events.push('durable');return {{item:message,background:true}};}},
|
||||
completeToday:item=>{{events.push('complete:' + item.number);return true;}},
|
||||
}});
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:27}};
|
||||
const pending=close(item).then(result=>{{events.push('resolved');return result;}});
|
||||
Promise.resolve().then(async()=>{{
|
||||
const before=events.slice();release();const result=await pending;
|
||||
process.stdout.write(JSON.stringify({{before,events,result}}));
|
||||
}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["before"] == ["admit"]
|
||||
assert output["events"] == ["admit", "durable", "complete:27", "resolved"]
|
||||
assert output["result"]["advanced"] is True
|
||||
assert output["result"]["admission"]["background"] is True
|
||||
@@ -264,7 +264,7 @@ async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_cont
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only():
|
||||
async def test_saved_requested_review_queues_complete_sha_scoped_feedback_offline():
|
||||
html = await dashboard()
|
||||
|
||||
assert "item.is_review ? reviewController.load(item)" in html
|
||||
@@ -272,6 +272,51 @@ async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only
|
||||
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
|
||||
assert "cachedDetail || await reviewController.load(selectedReview)" in html
|
||||
assert "Offline review · saved " in html
|
||||
assert "Reconnect to validate & submit" in html
|
||||
assert "qs('#submit-review').disabled = offlineReview;" in html
|
||||
assert "Queue review for reconnect" in html
|
||||
assert "kind: 'pull-review'" in html
|
||||
assert "await authoredOutbox.enqueueDurably" in html
|
||||
assert "expectedHeadSha: selectedReviewHead" in html
|
||||
assert "comments: snapshot.comments" in html
|
||||
assert "draftKey: draft.storageKey" in html
|
||||
assert "progressKey: progress?.storageKey" in html
|
||||
assert "draftFingerprint: localStorage.getItem(draft.storageKey) || ''" in html
|
||||
assert "progressFingerprint: localStorage.getItem(progress?.storageKey) || ''" in html
|
||||
assert "Review queued · it will submit after reconnect." in html
|
||||
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offline_today_review_queues_durably_before_completing_and_advancing():
|
||||
html = await dashboard()
|
||||
|
||||
assert "function reviewingActiveTodayItem()" in html
|
||||
assert "offlineReview && reviewingActiveTodayItem() ? 'Queue review & next'" in html
|
||||
handler = html.split("qs('#submit-review').addEventListener('click'", 1)[1].split(
|
||||
"qs('#continue-review-to-merge').addEventListener", 1
|
||||
)[0]
|
||||
admission = handler.index("await authoredOutbox.enqueueDurably")
|
||||
completion = handler.index("completeTodayItem(selectedReview")
|
||||
assert admission < completion
|
||||
assert "successMessage: 'Review queued. Next Today item opened.'" in handler
|
||||
assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler
|
||||
assert "if (!advanced)" in handler
|
||||
assert "Review queued, but Today still needs completion." in handler
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_offline_today_issue_queues_closure_before_completing_and_advancing():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/offline-issue-close.js"></script>' in html
|
||||
assert "const closeOfflineIssue = createOfflineIssueClose({" in html
|
||||
assert "enqueueDurably: message => authoredOutbox.enqueueDurably(message)" in html
|
||||
assert "completeToday: (item, options) => completeTodayItem(item, options)" in html
|
||||
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
|
||||
handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-pull-sheet').addEventListener", 1
|
||||
)[0]
|
||||
admission = handler.index("await closeOfflineIssue(closing)")
|
||||
sheet_close = handler.index("closeIssueSheet()")
|
||||
assert admission < sheet_close
|
||||
assert "Issue closure queued for reconnect." in handler
|
||||
assert "The issue remains in Today; retry." in handler
|
||||
@@ -168,6 +168,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -47,8 +47,9 @@ const context = {{
|
||||
}},
|
||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
||||
__issueSync: {{
|
||||
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
||||
purge: async () => {{ state.outboxPurges += 1; }},
|
||||
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
|
||||
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
|
||||
resume: async () => {{ state.backgroundResumes += 1; state.outboxLifecycle.push('resume'); }},
|
||||
getReceiptPreference: async login => state.receiptLogin === login,
|
||||
}},
|
||||
}},
|
||||
@@ -121,16 +122,24 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -138,14 +147,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -154,21 +163,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -232,6 +241,21 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const replies = [];
|
||||
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:value=>replies.push(value)}]);
|
||||
await dispatchMessage({type:'stackchain-resume-outbox'});
|
||||
process.stdout.write(JSON.stringify({state,replies}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
assert result["state"]["outboxLifecycle"] == ["purge", "resume", "flush"]
|
||||
assert result["state"]["backgroundResumes"] == 1
|
||||
|
||||
|
||||
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -334,7 +358,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -365,12 +389,15 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/offline-issue-close.js",
|
||||
"/dashboard/static/notification-read-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
|
||||
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "stackchain-dashboard-shell-v72" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
TODAY_COMPLETION = Path(__file__).parents[1] / "frontend" / "today-completion.js"
|
||||
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
@@ -87,6 +89,102 @@ process.stdout.write(JSON.stringify([
|
||||
]
|
||||
|
||||
|
||||
def test_done_for_today_removes_current_plan_item_syncs_and_advances_without_gitea_mutation():
|
||||
script = f"""
|
||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||
const calls = [];
|
||||
const current = {{kind:'issue', repository:'stackchain/dashboard', number:421}};
|
||||
const complete = createTodayCompletion({{
|
||||
todayWork: {{
|
||||
identity: item => `issue:${{item.repository}}:${{item.number}}:`,
|
||||
remove: item => {{ calls.push(`remove:${{item.number}}`); return true; }},
|
||||
}},
|
||||
todaySync: {{
|
||||
enqueue: (action, identity) => {{ calls.push(`sync:${{action}}:${{identity}}`); return true; }},
|
||||
flush: () => calls.push('flush'),
|
||||
}},
|
||||
refresh: () => calls.push('refresh'),
|
||||
warm: () => calls.push('warm'),
|
||||
workSession: {{ complete: () => calls.push('advance') }},
|
||||
announce: message => calls.push(`announce:${{message}}`),
|
||||
}});
|
||||
const completed = complete(current);
|
||||
process.stdout.write(JSON.stringify({{completed, calls}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"completed": True,
|
||||
"calls": [
|
||||
"remove:421",
|
||||
"sync:remove:issue:stackchain/dashboard:421:",
|
||||
"flush",
|
||||
"refresh",
|
||||
"warm",
|
||||
"advance",
|
||||
"announce:Done for Today. The Gitea item is unchanged.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_done_for_today_does_not_advance_when_local_plan_persistence_fails():
|
||||
script = f"""
|
||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||
const calls = [];
|
||||
const complete = createTodayCompletion({{
|
||||
todayWork: {{identity: () => 'issue:r:1:', remove: () => false}},
|
||||
todaySync: {{enqueue: () => calls.push('sync'), flush: () => calls.push('flush')}},
|
||||
refresh: () => calls.push('refresh'),
|
||||
warm: () => calls.push('warm'),
|
||||
workSession: {{complete: () => calls.push('advance')}},
|
||||
announce: message => calls.push(message),
|
||||
}});
|
||||
const completed = complete({{kind:'issue', repository:'r', number:1}});
|
||||
process.stdout.write(JSON.stringify({{completed, calls}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"completed": False,
|
||||
"calls": ["Could not update Today on this device. Try again."],
|
||||
}
|
||||
|
||||
|
||||
def test_today_completion_reports_context_specific_success_and_partial_failure():
|
||||
script = f"""
|
||||
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
|
||||
const messages = [];
|
||||
let removable = false;
|
||||
const complete = createTodayCompletion({{
|
||||
todayWork: {{identity: () => 'review:r:7:', remove: () => removable}},
|
||||
todaySync: {{enqueue: () => true, flush: () => undefined}},
|
||||
refresh: () => undefined,
|
||||
warm: () => undefined,
|
||||
workSession: {{complete: () => undefined}},
|
||||
announce: message => messages.push(message),
|
||||
}});
|
||||
const options = {{
|
||||
successMessage: 'Review queued. Next Today item opened.',
|
||||
failureMessage: 'Review queued, but Today still needs completion.',
|
||||
}};
|
||||
const failed = complete({{kind:'review', repository:'r', number:7}}, options);
|
||||
removable = true;
|
||||
const completed = complete({{kind:'review', repository:'r', number:7}}, options);
|
||||
process.stdout.write(JSON.stringify({{failed, completed, messages}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"failed": False,
|
||||
"completed": True,
|
||||
"messages": [
|
||||
"Review queued, but Today still needs completion.",
|
||||
"Review queued. Next Today item opened.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_done_for_today_logic_is_available_in_the_offline_app_shell():
|
||||
assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text()
|
||||
|
||||
|
||||
def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
@@ -183,6 +281,22 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
assert "Another device filled Today · showing its saved plan." in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_action():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/today-completion.js"></script>' in html
|
||||
assert html.count('data-work-session-complete=') == 4
|
||||
assert html.count('Done for Today & next') == 4
|
||||
assert "button.hidden = !workSession.checkpointed();" in html
|
||||
assert "const completeTodayItem = createTodayCompletion({" in html
|
||||
assert "refresh: () => refreshMyWorkView({ reconcileSession:false })," in html
|
||||
assert "document.querySelectorAll('[data-work-session-complete]')" in html
|
||||
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
|
||||
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
|
||||
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_activates_today_lifecycle_convergence():
|
||||
html = await dashboard()
|
||||
|
||||
Reference in new issue
Block a user