Compare commits

...
Author SHA1 Message Date
timmy f7073e1393 feat: triage unread updates in My Work (#151)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 5s
2026-08-07 00:35:34 +00:00
timmy 5b864c028e Keep live dashboard usable through failed refreshes (#150)
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #149
2026-08-06 23:55:38 +00:00
timmy 8ee7dbf79e feat: keep live snapshots usable through refresh failures (#149)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 23:54:55 +00:00
timmy db0354a6e5 Merge pull request 'Paginate unread updates without slowing live refresh' (#148) from timmy/147-paginated-unread-updates into main
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 23:31:27 +00:00
timmy a86de5cbf4 feat: paginate unread updates inbox (#147)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 23:30:32 +00:00
rockachopa 953f59fb19 Bound bulk notification fan-out and completion time (#146)
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #145
2026-08-06 22:53:54 +00:00
timmy 36d7fb9ce8 feat: bound bulk notification fan-out (#145)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 22:52:48 +00:00
timmy 955343a428 Keep mobile review handoff working when popups are blocked (#144)
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 22:26:13 +00:00
timmy b51542363c fix: keep mobile review handoff popup-safe (#143)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 22:25:22 +00:00
rockachopa 54638d2686 Merge pull request 'Bulk acknowledge unread updates from mobile My Work' (#142) from timmy/141-bulk-notification-triage into main
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 21:56:41 +00:00
timmy db9e51e683 feat: bulk acknowledge unread updates (#141)
CI / lint (pull_request) Successful in 11s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 21:55:32 +00:00
rockachopa cafc062b9f Retain live snapshots while revalidating (#140)
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Closes #139
2026-08-06 21:33:48 +00:00
timmy ea7bd15721 feat: retain live snapshots while revalidating (#139)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 21:32:58 +00:00
rockachopa 3807e7afd1 Merge pull request 'Keep pooled live snapshots cancellation-safe' (#138) from timmy/137-cancellation-safe-live-snapshot into main
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Merge pull request 'Keep pooled live snapshots cancellation-safe' (#138) from timmy/137-cancellation-safe-live-snapshot into main
2026-08-06 20:52:38 +00:00
timmy 0dc5c2c831 fix: keep pooled live snapshots cancellation-safe (#137)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 20:51:53 +00:00
rockachopa efb3ae3400 Merge pull request 'Acknowledge unread updates from the mobile My Work inbox' (#136) from timmy/135-notification-mark-read into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Merge pull request 'Acknowledge unread updates from the mobile My Work inbox' (#136)
2026-08-06 20:31:48 +00:00
14 changed files with 2013 additions and 79 deletions

No files matched your search

+294 -20
View File
@@ -66,6 +66,11 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.mark-update-read { min-height:44px; width:100%; }
.read-update { min-height:44px; width:100%; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
.load-more-notifications[hidden] { display:none; }
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.my-work-bulk button { min-height:44px; width:100%; }
.my-work[data-stale="true"] { border-color:#fcd34d; }
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.review-sheet.open { display:flex; }
@@ -85,6 +90,8 @@ textarea { resize: vertical; min-height: 120px; }
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff button { min-height:44px; }
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
.review-handoff-link[hidden] { display:none; }
.review-copy-fallback { min-height:140px; }
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
.review-progress-actions button { min-height:44px; max-width:100%; }
@@ -96,6 +103,15 @@ textarea { resize: vertical; min-height: 120px; }
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
.review-action { min-height:44px; }
.review-retry { min-height:44px; margin-top:10px; }
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.update-sheet.open { display:flex; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
@media (max-width: 600px) {
header { align-items:flex-start; }
.my-work { margin:0; }
@@ -103,6 +119,7 @@ textarea { resize: vertical; min-height: 120px; }
.work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@@ -137,7 +154,12 @@ textarea { resize: vertical; min-height: 120px; }
</div>
</div>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
<button id="bulk-mark-read" type="button"></button>
</div>
</section>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
@@ -224,6 +246,35 @@ textarea { resize: vertical; min-height: 120px; }
</div>
</div>
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel">
<div class="update-sheet-header">
<div>
<div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3>
</div>
<button id="keep-update-unread" type="button">Keep unread</button>
</div>
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
<div class="row">
<span class="pill" id="update-subject-type">Update</span>
<span class="pill" id="update-subject-state"></span>
</div>
<h2>Latest comment</h2>
<div class="small" id="update-comment-meta"></div>
<p class="update-sheet-content" id="update-comment-body"></p>
<details>
<summary><h2>Subject context</h2></summary>
<p class="update-sheet-content muted" id="update-subject-body"></p>
</details>
<div class="update-sheet-actions">
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
</div>
<div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title">
<section class="review-sheet-panel">
<div class="review-sheet-header">
@@ -259,6 +310,7 @@ textarea { resize: vertical; min-height: 120px; }
<button id="copy-review-feedback">Copy feedback &amp; continue in Gitea</button>
<span class="small" id="review-handoff-status" aria-live="polite"></span>
<textarea class="review-copy-fallback" id="review-copy-fallback" readonly hidden aria-label="Feedback to copy manually"></textarea>
<a class="review-handoff-link" id="review-handoff-link" target="_blank" rel="noopener noreferrer" hidden>Continue to Gitea</a>
</div>
</section>
<h2>Review history</h2>
@@ -310,12 +362,19 @@ textarea { resize: vertical; min-height: 120px; }
}
let lastMyWork = [];
let lastNotifications = [];
let lastContextSnapshot = null;
let notificationPagination = { page: 1, total: 0, has_more: false };
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
let selectedUpdate = null;
let updateTrigger = null;
let progress = null;
let draft = null;
let reviewFiles = [];
let bulkConfirmationPending = false;
let bulkMarkPending = false;
let reviewHandoffPending = false;
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
@@ -345,6 +404,35 @@ textarea { resize: vertical; min-height: 120px; }
return payload;
}
async function markNotificationsRead(ids) {
const response = await fetch('api/v1/notifications/read', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Bulk mark read failed.');
return payload;
}
async function fetchNotificationPage(page) {
const response = await fetch('api/v1/notifications?page=' + encodeURIComponent(page), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading unread updates failed.');
return payload;
}
async function fetchNotificationDetail(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId), {
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Loading the update failed.');
return payload;
}
const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead,
onItems: items => {
@@ -353,10 +441,83 @@ textarea { resize: vertical; min-height: 120px; }
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const bulkNotificationAcknowledger = createBulkNotificationAcknowledger({
markRead: markNotificationsRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationPager = createNotificationPager({
load: fetchNotificationPage,
onNotifications: items => {
lastNotifications = items;
if (lastContextSnapshot) {
lastContextSnapshot.notifications = lastNotifications;
paintMyWork(lastContextSnapshot);
}
},
onPagination: pagination => {
notificationPagination = pagination;
const loaded = Math.min(pagination.total, pagination.page * 50);
qs('#notification-page-status').textContent = pagination.total ?
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !pagination.has_more;
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
markRead: markNotificationRead,
onOpen: item => {
selectedUpdate = item;
qs('#update-sheet').classList.add('open');
qs('#update-sheet-key').textContent = item.key || '';
qs('#update-sheet-title').textContent = item.title || 'Unread update';
qs('#update-comment-meta').textContent = '';
qs('#update-comment-body').textContent = '';
qs('#update-subject-body').textContent = '';
qs('#update-subject-type').textContent = item.subject_type || 'Update';
qs('#update-subject-state').textContent = item.state || '';
qs('#open-update-gitea').href = item.url || '#';
qs('#retry-update-load').hidden = true;
qs('#keep-update-unread').focus();
},
onDetail: detail => {
const comment = detail.latest_comment || {};
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || '';
qs('#update-comment-meta').textContent = comment.author ?
comment.author + (comment.created_at ? ' · ' + fmt(comment.created_at) : '') :
(comment.created_at ? fmt(comment.created_at) : 'No comment author reported');
qs('#update-comment-body').textContent = comment.body || 'No comment body was provided.';
qs('#update-subject-body').textContent = detail.subject_body || 'No subject context was provided.';
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#retry-update-load').hidden = true;
},
onItems: items => {
const readId = selectedUpdate?.notification_id;
lastMyWork = items;
if (Number.isInteger(readId)) {
lastNotifications = lastNotifications.filter(item => item.id !== readId);
}
refreshMyWorkView();
},
onStatus: message => {
qs('#update-sheet-status').textContent = message;
qs('#retry-update-load').hidden = !message.startsWith('Could not load update.');
if (message === 'Inbox cleared.') qs('#my-work-action-status').textContent = message;
},
onClose: () => closeUpdateSheet(false),
});
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
lastContextSnapshot = data;
if (data.error && lastMyWork.length) markMyWorkStale();
else paintMyWork(data);
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
@@ -416,6 +577,7 @@ textarea { resize: vertical; min-height: 120px; }
function renderMyWork() {
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const contents =
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
@@ -424,15 +586,24 @@ textarea { resize: vertical; min-height: 120px; }
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
'<button class="read-update" data-update-index="' + index + '">Read update</button>' : '';
if (item.is_review) {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + markRead + '</article>';
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
}
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + markRead + '</article>';
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>';
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>';
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
});
document.querySelectorAll('[data-update-index]').forEach(button => {
button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.updateIndex)];
if (!item) return;
updateTrigger = button;
notificationReader.open(item, lastMyWork);
});
});
document.querySelectorAll('[data-notification-id]').forEach(button => {
button.addEventListener('click', async () => {
const notificationId = Number(button.dataset.notificationId);
@@ -446,6 +617,20 @@ textarea { resize: vertical; min-height: 120px; }
}
});
});
const allIds = notificationIds(visible);
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
const bulkBar = qs('#bulk-mark-read-bar');
const bulkButton = qs('#bulk-mark-read');
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
bulkButton.disabled = bulkMarkPending;
const bulkLabel = allIds.length > ids.length ?
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
'all ' + ids.length + ' updates';
bulkButton.textContent = bulkConfirmationPending ?
'Confirm marking ' + bulkLabel + ' read' :
'Mark ' + bulkLabel + ' read';
}
function reviewFileElement(filename) {
@@ -499,6 +684,8 @@ textarea { resize: vertical; min-height: 120px; }
qs('#review-summary').value = '';
qs('#review-copy-fallback').hidden = true;
qs('#review-copy-fallback').value = '';
qs('#review-handoff-link').hidden = true;
qs('#review-handoff-link').href = item.url;
qs('#review-handoff-status').textContent = '';
draft = null;
reviewFiles = [];
@@ -571,6 +758,13 @@ textarea { resize: vertical; min-height: 120px; }
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
function closeUpdateSheet(restoreTrigger = true) {
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
else qs('[data-work-filter="update"]')?.focus();
}
function markMyWorkStale() {
qs('#my-work').setAttribute('data-stale', 'true');
qs('#my-work-status').textContent = lastMyWork.length ?
@@ -605,7 +799,24 @@ textarea { resize: vertical; min-height: 120px; }
function renderLiveSnapshot(snapshot) {
const notificationsFresh = Array.isArray(snapshot.notifications);
if (notificationsFresh) lastNotifications = snapshot.notifications;
if (notificationsFresh) {
if (notificationPagination.page > 1) {
const byId = new Map(lastNotifications.map(item => [item.id, item]));
snapshot.notifications.forEach(item => byId.set(item.id, item));
lastNotifications = Array.from(byId.values());
} else {
lastNotifications = snapshot.notifications;
}
if (snapshot.notification_pagination) {
const page = notificationPagination.page > 1 ? notificationPagination.page :
snapshot.notification_pagination.page;
notificationPager.reset({
page,
total: snapshot.notification_pagination.total,
has_more: page * 50 < snapshot.notification_pagination.total,
});
}
}
if (snapshot.context) {
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
@@ -617,6 +828,13 @@ textarea { resize: vertical; min-height: 120px; }
} else {
setEventStreamStatus('Update failed · showing last activity');
}
if (snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
setEventStreamStatus('Refresh failed · showing last known data' +
(retrySeconds > 0 ? ' · retrying in ' + retrySeconds + 's' : ''));
} else if (snapshot.freshness?.revalidating) {
setEventStreamStatus('Refreshing · showing recent snapshot');
}
}
@@ -688,6 +906,18 @@ textarea { resize: vertical; min-height: 120px; }
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
});
qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true;
try {
await notificationReader.markReadAndNext(lastMyWork);
} finally {
qs('#mark-update-read-next').disabled = false;
}
});
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
@@ -698,24 +928,43 @@ textarea { resize: vertical; min-height: 120px; }
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
qs('#copy-review-feedback').addEventListener('click', async () => {
if (!draft || !selectedReview) return;
if (!draft || !selectedReview || reviewHandoffPending) return;
reviewHandoffPending = true;
qs('#copy-review-feedback').disabled = true;
const fallback = qs('#review-copy-fallback');
const directLink = qs('#review-handoff-link');
fallback.hidden = true;
const copied = await createReviewController.copyAndContinue({
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
url: selectedReview.url,
copy: text => navigator.clipboard.writeText(text),
open: url => window.open(url, '_blank', 'noopener,noreferrer'),
fallback: text => {
fallback.value = text;
fallback.hidden = false;
fallback.focus();
fallback.select();
},
});
qs('#review-handoff-status').textContent = copied ?
'Feedback copied. Opening Gitea…' :
'Clipboard unavailable. Copy the selected feedback, then use Open in Gitea.';
directLink.hidden = true;
let handoffWindow = null;
try {
const result = await createReviewController.copyAndContinue({
text: createReviewController.formatFeedback(draft.snapshot(), reviewFiles),
url: selectedReview.url,
copy: text => navigator.clipboard.writeText(text),
open: () => {
handoffWindow = window.open('about:blank', '_blank');
if (handoffWindow) handoffWindow.opener = null;
return handoffWindow;
},
fallback: text => {
fallback.value = text;
fallback.hidden = false;
fallback.focus();
fallback.select();
},
});
if (result.opened) {
qs('#review-handoff-status').textContent = 'Feedback copied. Gitea opened in a new tab.';
} else {
directLink.hidden = false;
qs('#review-handoff-status').textContent = result.copied ?
'Feedback copied. Your browser blocked the new tab; continue with the link below.' :
'Clipboard unavailable. Copy the selected feedback, then continue to Gitea.';
}
} finally {
reviewHandoffPending = false;
qs('#copy-review-feedback').disabled = false;
}
});
const contextPoller = createContextPoller({
@@ -731,6 +980,31 @@ textarea { resize: vertical; min-height: 120px; }
function load() { return contextPoller.refresh(); }
qs('#refresh').addEventListener('click', load);
qs('#load-more-notifications').addEventListener('click', () =>
notificationPager.loadMore(lastNotifications)
);
qs('#bulk-mark-read').addEventListener('click', async () => {
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
const ids = allIds.slice(0, 50);
if (!ids.length || bulkMarkPending) return;
if (!bulkConfirmationPending) {
bulkConfirmationPending = true;
qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
renderMyWork();
return;
}
bulkConfirmationPending = false;
bulkMarkPending = true;
renderMyWork();
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
if (result) {
const marked = new Set(result.marked);
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
}
bulkMarkPending = false;
renderMyWork();
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
});
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => {
+139
View File
@@ -91,6 +91,141 @@ function createNotificationAcknowledger({ markRead, onItems, onStatus }) {
};
}
function notificationIds(items) {
return Array.from(new Set((items || [])
.filter((item) => item && item.has_update && Number.isInteger(item.notification_id))
.map((item) => item.notification_id)));
}
function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
let pending = false;
return {
async acknowledge(items, requestedIds) {
if (pending) return false;
const ids = Array.from(new Set((requestedIds || []).filter(Number.isInteger)));
if (!ids.length) return false;
pending = true;
onStatus('Marking ' + ids.length + ' updates read…');
try {
const result = await markRead(ids);
const marked = (result.marked || []).filter(Number.isInteger);
const failed = (result.failed || []).filter(Number.isInteger);
const updated = marked.reduce(
(current, notificationId) => acknowledgeNotification(current, notificationId),
items
);
onItems(updated);
onStatus(failed.length ?
marked.length + ' marked read · ' + failed.length + ' could not be updated — retry.' :
marked.length + ' updates marked read.');
return { marked, failed };
} catch (_error) {
onStatus('Could not mark updates read. Retry.');
return false;
} finally {
pending = false;
}
},
};
}
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
return {
reset(next) {
pagination = { ...pagination, ...(next || {}) };
onPagination(pagination);
},
async loadMore(existing) {
if (pending || !pagination.has_more) return false;
pending = true;
onStatus('Loading older updates…');
try {
const result = await load(pagination.page + 1);
const byId = new Map((existing || [])
.filter(item => item && Number.isInteger(item.id))
.map(item => [item.id, item]));
(result.items || []).forEach(item => {
if (item && Number.isInteger(item.id) && !byId.has(item.id)) byId.set(item.id, item);
});
pagination = {
page: result.page,
total: result.total,
has_more: result.has_more === true,
};
const loaded = Math.min(pagination.total, pagination.page * 50);
onNotifications(Array.from(byId.values()));
onPagination(pagination);
onStatus(loaded + ' of ' + pagination.total + ' unread updates loaded.');
return true;
} catch (_error) {
onStatus('Could not load older updates. Retry.');
return false;
} finally {
pending = false;
}
},
};
}
function createNotificationReader({ load, markRead, onOpen, onDetail, onItems, onStatus, onClose }) {
let selected = null;
let loadVersion = 0;
let marking = false;
async function open(item) {
selected = item;
const version = ++loadVersion;
onOpen(item);
onStatus('Loading update…');
try {
const detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
onStatus('Update ready.');
return true;
} catch (_error) {
if (selected === item && version === loadVersion) {
onStatus('Could not load update. Retry or open it in Gitea.');
}
return false;
}
}
return {
open,
async markReadAndNext(items) {
if (!selected || marking) return false;
const current = selected;
marking = true;
onStatus('Marking update read…');
try {
await markRead(current.notification_id);
const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item =>
item && item.has_update && Number.isInteger(item.notification_id)
);
if (next) await open(next);
else {
selected = null;
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
} catch (_error) {
onStatus('Could not mark update read. Retry.');
return false;
} finally {
marking = false;
}
},
};
}
function filterMyWork(items, selectedFilter) {
if (selectedFilter === 'all') return items;
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
@@ -124,5 +259,9 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createNotificationReader = createNotificationReader;
module.exports = buildMyWork;
}
+9 -3
View File
@@ -166,14 +166,20 @@ function formatFeedback(draft, files) {
}
async function copyAndContinue({ text, url, copy, open, fallback }) {
const destination = open();
try {
await copy(text);
} catch (error) {
destination?.close?.();
fallback(text);
return false;
return { copied: false, opened: false };
}
open(url);
return true;
if (!destination) {
fallback(text);
return { copied: true, opened: false };
}
destination.location.href = url;
return { copied: true, opened: true };
}
createReviewController.renderDiffFile = renderDiffFile;
+92 -2
View File
@@ -125,8 +125,7 @@ def _safe_web_url(value: Any) -> str:
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
async def notifications() -> list[dict]:
threads = await fetch("notifications?status-types=unread&limit=50")
def _normalize_notifications(threads: Any) -> list[dict]:
if not isinstance(threads, list):
raise ValueError("Gitea notification response was not a list")
normalized = []
@@ -181,6 +180,29 @@ async def notifications() -> list[dict]:
return normalized
async def notifications() -> dict:
return await notification_page(1)
async def notification_page(page: int, limit: int = 50) -> dict:
response = await _get_client().get(
f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}",
headers=_auth(),
)
response.raise_for_status()
items = _normalize_notifications(response.json())
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {
"items": items,
"page": page,
"total": total,
"has_more": page * limit < total,
}
async def mark_notification_read(thread_id: int) -> None:
response = await _get_client().patch(
f"/api/v1/notifications/threads/{thread_id}?to-status=read",
@@ -189,6 +211,74 @@ async def mark_notification_read(thread_id: int) -> None:
response.raise_for_status()
def _gitea_api_path(value: Any) -> str:
if not isinstance(value, str):
return ""
parsed = urlsplit(value)
configured = urlsplit(GITEA_URL)
prefix = "/api/v1/"
if (
parsed.scheme not in {"http", "https"}
or parsed.netloc != configured.netloc
or not parsed.path.startswith(prefix)
):
return ""
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
async def notification_detail(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository_value = thread.get("repository")
repository = repository_value if isinstance(repository_value, dict) else {}
subject_value = thread.get("subject")
subject = subject_value if isinstance(subject_value, dict) else {}
subject_path = _gitea_api_path(subject.get("url"))
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
subject_detail = await fetch(subject_path) if subject_path else {}
comment = await fetch(comment_path) if comment_path else {}
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
comment = comment if isinstance(comment, dict) else {}
user_value = comment.get("user")
user = user_value if isinstance(user_value, dict) else {}
subject_url = _safe_web_url(subject.get("html_url"))
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url(
subject.get("latest_comment_html_url")
)
return {
"id": thread_id,
"repository": repository.get("full_name", "")
if isinstance(repository.get("full_name"), str)
else "",
"title": subject.get("title", "")
if isinstance(subject.get("title"), str)
else "",
"subject_type": subject.get("type", "Update")
if isinstance(subject.get("type"), str)
else "Update",
"state": subject.get("state", "")
if isinstance(subject.get("state"), str)
else "",
"url": latest_url or subject_url,
"subject_body": subject_detail.get("body", "")
if isinstance(subject_detail.get("body"), str)
else "",
"latest_comment": {
"author": user.get("login", "")
if isinstance(user.get("login"), str)
else "",
"body": comment.get("body", "")
if isinstance(comment.get("body"), str)
else "",
"created_at": comment.get("created_at", "")
if isinstance(comment.get("created_at"), str)
else "",
"url": latest_url,
},
}
async def pull_requests() -> list[dict]:
assigned, review_requested = await asyncio.gather(
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
+312 -17
View File
@@ -1,12 +1,14 @@
import asyncio
import math
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Path as PathParam
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt
from src import gitea_proxy
from src.gitea_proxy import (
@@ -15,6 +17,7 @@ from src.gitea_proxy import (
is_requested_review,
issues,
mark_notification_read,
notification_detail,
notifications,
pull_requests,
pull_review_detail,
@@ -26,11 +29,23 @@ from src.views import router as frontend_router
@asynccontextmanager
async def lifespan(_app: FastAPI):
global _live_snapshot_task
gitea_proxy.start_client()
try:
yield
finally:
await gitea_proxy.stop_client()
task = _live_snapshot_task
if task is not None and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
try:
await gitea_proxy.stop_client()
finally:
if _live_snapshot_task is task:
_live_snapshot_task = None
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
@@ -39,8 +54,20 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
NOTIFICATION_DETAIL_TIMEOUT_SECONDS = 5.0
BULK_NOTIFICATION_CONCURRENCY = 5
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
_live_snapshot_created_at: float | None = None
_live_snapshot_failure_count = 0
_live_snapshot_retry_at: float | None = None
_read_notification_ids: set[int] = set()
class ContextPayloadError(ValueError):
@@ -51,6 +78,10 @@ class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload."""
class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user_model = User(
id=user_data["id"],
@@ -114,10 +145,7 @@ async def prevent_live_api_caching(request, call_next):
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
) or (
request.url.path.startswith("/api/v1/notifications/")
and request.url.path.endswith("/read")
):
) or request.url.path.startswith("/api/v1/notifications"):
response.headers["Cache-Control"] = "no-store"
return response
@@ -239,10 +267,21 @@ async def _build_live_snapshot() -> dict:
context_ok = not isinstance(context_result, BaseException)
events_ok = not isinstance(events_result, BaseException)
notifications_ok = not isinstance(notifications_result, BaseException)
notification_items = notifications_result
notification_pagination = None
if notifications_ok and isinstance(notifications_result, dict):
notification_items = notifications_result.get("items")
notification_pagination = {
"page": notifications_result.get("page", 1),
"total": notifications_result.get("total", 0),
"has_more": notifications_result.get("has_more") is True,
}
notifications_ok = isinstance(notification_items, list)
return {
"context": context_result if context_ok else None,
"events": events_result if events_ok else None,
"notifications": notifications_result if notifications_ok else None,
"notifications": notification_items if notifications_ok else None,
"notification_pagination": notification_pagination if notifications_ok else None,
"sections": {
"context": "fresh" if context_ok else "temporarily unavailable",
"events": "fresh" if events_ok else "temporarily unavailable",
@@ -251,16 +290,179 @@ async def _build_live_snapshot() -> dict:
}
@app.get("/api/v1/live")
async def live_snapshot() -> JSONResponse:
"""Return a fresh, section-aware snapshot; join only an active identical load."""
async def _build_live_snapshot_before_deadline() -> dict:
async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS):
return await _build_live_snapshot()
def _record_live_snapshot_failure() -> None:
global _live_snapshot_failure_count, _live_snapshot_retry_at
_live_snapshot_failure_count += 1
delay = min(
LIVE_SNAPSHOT_RETRY_MAX_SECONDS,
LIVE_SNAPSHOT_RETRY_BASE_SECONDS * (2 ** (_live_snapshot_failure_count - 1)),
)
_live_snapshot_retry_at = time.monotonic() + delay
def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
if previous is None:
return refreshed
merged = dict(refreshed)
sections = dict(refreshed.get("sections") or {})
for section in ("context", "events", "notifications"):
if sections.get(section) == "fresh":
continue
if previous.get(section) is not None:
merged[section] = previous[section]
sections[section] = "stale"
if section == "notifications":
merged["notification_pagination"] = previous.get(
"notification_pagination"
)
merged["sections"] = sections
return merged
async def _refresh_live_snapshot() -> dict:
global _live_snapshot_value, _live_snapshot_created_at
global _live_snapshot_failure_count, _live_snapshot_retry_at
try:
result = await _build_live_snapshot_before_deadline()
except Exception:
_record_live_snapshot_failure()
raise
result = _merge_live_snapshot(_live_snapshot_value, result)
result = _without_read_notifications(result)
_live_snapshot_value = result
_live_snapshot_created_at = time.monotonic()
if any(state != "fresh" for state in result.get("sections", {}).values()):
_record_live_snapshot_failure()
else:
_live_snapshot_failure_count = 0
_live_snapshot_retry_at = None
return result
def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
if task.cancelled():
return
task.exception()
def _start_live_snapshot_refresh() -> asyncio.Task:
global _live_snapshot_task
if _live_snapshot_task is None or _live_snapshot_task.done():
_live_snapshot_task = asyncio.create_task(_build_live_snapshot())
task = _live_snapshot_task
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot())
_live_snapshot_task.add_done_callback(_consume_live_snapshot_failure)
return _live_snapshot_task
def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> dict:
payload = dict(value)
age = (
max(0.0, time.monotonic() - _live_snapshot_created_at)
if _live_snapshot_created_at is not None
else 0.0
)
retry_in_seconds = (
max(0.0, _live_snapshot_retry_at - time.monotonic())
if _live_snapshot_retry_at is not None
else 0.0
)
payload["freshness"] = {
"age_seconds": round(age, 3),
"fresh_for_seconds": LIVE_SNAPSHOT_FRESHNESS_SECONDS,
"stale": stale,
"revalidating": revalidating,
"degraded": _live_snapshot_retry_at is not None,
"last_refresh_failed": _live_snapshot_retry_at is not None,
"retry_in_seconds": math.ceil(retry_in_seconds),
}
return payload
def _remove_notification_from_live_snapshot(thread_id: int) -> None:
global _live_snapshot_value, _read_notification_ids
_read_notification_ids = _read_notification_ids | {thread_id}
if _live_snapshot_value is None:
return
retained_notifications = _live_snapshot_value.get("notifications")
if not isinstance(retained_notifications, list):
return
updated = dict(_live_snapshot_value)
updated["notifications"] = [
notification
for notification in retained_notifications
if not isinstance(notification, dict) or notification.get("id") != thread_id
]
_live_snapshot_value = updated
def _without_read_notifications(snapshot: dict) -> dict:
global _read_notification_ids
snapshot_notifications = snapshot.get("notifications")
if not isinstance(snapshot_notifications, list):
return snapshot
returned_ids = {
notification.get("id")
for notification in snapshot_notifications
if isinstance(notification, dict)
}
updated = dict(snapshot)
updated["notifications"] = [
notification
for notification in snapshot_notifications
if not isinstance(notification, dict)
or notification.get("id") not in _read_notification_ids
]
_read_notification_ids = _read_notification_ids.intersection(returned_ids)
return updated
@app.get("/api/v1/live")
async def live_snapshot() -> JSONResponse:
"""Return a freshness-bounded snapshot and share identical upstream loads."""
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
now = time.monotonic()
if (
_live_snapshot_value is not None
and _live_snapshot_retry_at is not None
):
if now < _live_snapshot_retry_at:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=False
)
)
_start_live_snapshot_refresh()
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=True
)
)
if (
_live_snapshot_value is not None
and _live_snapshot_created_at is not None
and now - _live_snapshot_created_at < LIVE_SNAPSHOT_FRESHNESS_SECONDS
):
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=False, revalidating=False
)
)
task = _start_live_snapshot_refresh()
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=True
)
)
try:
result = await asyncio.wait_for(task, timeout=CONTEXT_TIMEOUT_SECONDS)
return JSONResponse(result)
result = await asyncio.shield(task)
return JSONResponse(
_live_snapshot_payload(result, stale=False, revalidating=False)
)
except TimeoutError:
return JSONResponse(
{"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"},
@@ -272,9 +474,7 @@ async def live_snapshot() -> JSONResponse:
{"error": "Gitea live snapshot is temporarily unavailable"},
status_code=503,
)
finally:
if task.done() and _live_snapshot_task is task:
_live_snapshot_task = None
@app.get("/api/v1/events")
@@ -310,6 +510,100 @@ async def event_stream():
)
async def _mark_notification_read_result(thread_id: int) -> tuple[int, bool]:
try:
await asyncio.wait_for(
mark_notification_read(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except Exception:
return thread_id, False
_remove_notification_from_live_snapshot(thread_id)
return thread_id, True
@app.patch("/api/v1/notifications/read")
async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
thread_ids = list(dict.fromkeys(batch.ids))
semaphore = asyncio.Semaphore(BULK_NOTIFICATION_CONCURRENCY)
async def mark_within_limit(thread_id: int) -> tuple[int, bool]:
async with semaphore:
return await _mark_notification_read_result(thread_id)
tasks = [asyncio.create_task(mark_within_limit(thread_id)) for thread_id in thread_ids]
done, pending = await asyncio.wait(
tasks, timeout=BULK_NOTIFICATION_DEADLINE_SECONDS
)
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
succeeded_ids = {
thread_id
for task in done
if not task.cancelled() and task.exception() is None
for thread_id, succeeded in [task.result()]
if succeeded
}
failed = [thread_id for thread_id in thread_ids if thread_id not in succeeded_ids]
return JSONResponse(
{
"marked": [thread_id for thread_id in thread_ids if thread_id in succeeded_ids],
"failed": failed,
},
headers={"Retry-After": "1"} if failed else None,
)
@app.get("/api/v1/notifications")
async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.notification_page(page),
timeout=NOTIFICATION_PAGE_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Unread updates timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Unread updates are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/notifications/{thread_id}")
async def notification_thread_detail(
thread_id: int = PathParam(gt=0),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
notification_detail(thread_id),
timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Loading the update timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The update is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/notifications/{thread_id}/read")
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
@@ -328,6 +622,7 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
{"error": "The update could not be marked read. Please retry."},
status_code=503,
)
_remove_notification_from_live_snapshot(thread_id)
return JSONResponse({"id": thread_id, "status": "read"})
+2
View File
@@ -17,4 +17,6 @@ def test_api_requests_resolve_inside_dashboard_subpath():
} == {
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications?page=",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/read",
}
+132 -18
View File
@@ -1,15 +1,49 @@
import httpx
import pytest
from src import gitea_proxy
@pytest.mark.anyio
async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handoff(monkeypatch):
requested_paths = []
async def test_notification_page_preserves_upstream_total_without_loading_other_pages():
requests = []
async def fake_fetch(path):
requested_paths.append(path)
return [
def upstream(request):
requests.append(str(request.url))
return httpx.Response(
200,
headers={"X-Total-Count": "125"},
json=[{
"id": 42,
"unread": True,
"repository": {"full_name": "stackchain/api"},
"subject": {
"title": "Retry failed deploy",
"type": "Issue",
"html_url": "https://forge.example/stackchain/api/issues/7",
},
}],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.notification_page(page=2)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/notifications?status-types=unread&limit=50&page=2"
]
assert result["page"] == 2
assert result["total"] == 125
assert result["has_more"] is True
assert [item["id"] for item in result["items"]] == [42]
@pytest.mark.anyio
async def test_unread_notifications_are_normalized_for_mobile_handoff():
result = gitea_proxy._normalize_notifications(
[
{
"id": 42,
"unread": True,
@@ -31,12 +65,7 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
},
"malformed",
]
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
result = await gitea_proxy.notifications()
assert requested_paths == ["notifications?status-types=unread&limit=50"]
)
assert result == [
{
"id": 42,
@@ -78,11 +107,96 @@ async def test_unread_notifications_are_bounded_and_normalized_for_mobile_handof
@pytest.mark.anyio
async def test_notification_collection_rejects_non_list_payload(monkeypatch):
async def fake_fetch(_path):
return {"message": "unexpected"}
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
async def test_notification_collection_rejects_non_list_payload():
with pytest.raises(ValueError, match="notification response was not a list"):
await gitea_proxy.notifications()
gitea_proxy._normalize_notifications({"message": "unexpected"})
@pytest.mark.anyio
async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_reader():
requests = []
def upstream(request):
requests.append(str(request.url))
if request.url.path.endswith("/notifications/threads/42"):
return httpx.Response(200, json={
"id": 42,
"repository": {"full_name": "stackchain/api"},
"subject": {
"title": "Retry failed deploy",
"type": "Issue",
"state": "open",
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"latest_comment_url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"html_url": "https://forge.example/stackchain/api/issues/7",
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
})
if request.url.path.endswith("/issues/7"):
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
if request.url.path.endswith("/issues/comments/9"):
return httpx.Response(200, json={
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"user": {"login": "alexander"},
"html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.notification_detail(42)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/notifications/threads/42",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
]
assert result == {
"id": 42,
"repository": "stackchain/api",
"title": "Retry failed deploy",
"subject_type": "Issue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"subject_body": "Deploy fails after **three** retries.",
"latest_comment": {
"author": "alexander",
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
}
@pytest.mark.anyio
async def test_notification_detail_never_follows_foreign_api_urls():
requests = []
def upstream(request):
requests.append(str(request.url))
return httpx.Response(200, json={
"id": 42,
"repository": {"full_name": "stackchain/api"},
"subject": {
"title": "Suspicious update",
"url": "https://evil.example/api/v1/user",
"latest_comment_url": "https://evil.example/api/v1/admin/users",
"html_url": "javascript:alert(1)",
},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.notification_detail(42)
finally:
await gitea_proxy.stop_client()
assert requests == [
"http://127.0.0.1:3000/api/v1/notifications/threads/42"
]
assert result["url"] == ""
assert result["subject_body"] == ""
assert result["latest_comment"]["body"] == ""
+38
View File
@@ -1,3 +1,5 @@
import asyncio
import httpx
import pytest
@@ -41,3 +43,39 @@ async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch
assert calls == ["start"]
assert calls == ["start", "stop"]
@pytest.mark.anyio
async def test_application_shutdown_finishes_snapshot_before_closing_transport(monkeypatch):
calls = []
started = asyncio.Event()
async def active_snapshot():
started.set()
try:
await asyncio.Event().wait()
finally:
calls.append("snapshot cancelled")
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
assert main._live_snapshot_task is not None
state = "done" if main._live_snapshot_task.done() else "active"
calls.append(f"stop ({state})")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
try:
async with main.app.router.lifespan_context(main.app):
main._live_snapshot_task = asyncio.create_task(active_snapshot())
await started.wait()
finally:
task = main._live_snapshot_task
if task is not None and not task.done():
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
main._live_snapshot_task = None
assert calls == ["start", "snapshot cancelled", "stop (done)"]
+285 -3
View File
@@ -9,8 +9,16 @@ from src import main
@pytest.fixture(autouse=True)
def reset_live_snapshot_task():
main._live_snapshot_task = None
main._live_snapshot_value = None
main._live_snapshot_created_at = None
main._live_snapshot_failure_count = 0
main._live_snapshot_retry_at = None
yield
main._live_snapshot_task = None
main._live_snapshot_value = None
main._live_snapshot_created_at = None
main._live_snapshot_failure_count = 0
main._live_snapshot_retry_at = None
def payload(response):
@@ -33,7 +41,12 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
return [{"type": "push"}]
async def updates():
return [{"id": 42, "title": "Mentioned you"}]
return {
"items": [{"id": 42, "title": "Mentioned you"}],
"page": 1,
"total": 125,
"has_more": True,
}
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
@@ -49,6 +62,9 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
assert result["context"]["user"]["login"] == "timmy"
assert result["events"] == [{"type": "push"}]
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
assert result["notification_pagination"] == {
"page": 1, "total": 125, "has_more": True
}
assert result["sections"] == {
"context": "fresh", "events": "fresh", "notifications": "fresh"
}
@@ -147,7 +163,7 @@ async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
@pytest.mark.anyio
async def test_live_snapshot_coalesces_only_simultaneous_requests(monkeypatch):
async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(monkeypatch):
user_calls = 0
release = asyncio.Event()
@@ -183,4 +199,270 @@ async def test_live_snapshot_coalesces_only_simultaneous_requests(monkeypatch):
await asyncio.gather(first, second)
await main.live_snapshot()
assert user_calls == 2
assert user_calls == 1
@pytest.mark.anyio
async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(monkeypatch):
now = 100.0
refresh_started = asyncio.Event()
release_refresh = asyncio.Event()
builds = 0
async def snapshot():
nonlocal builds
builds += 1
if builds == 2:
refresh_started.set()
await release_refresh.wait()
return {
"context": {"generation": builds},
"events": [],
"notifications": [],
"sections": {},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
first = payload(await main.live_snapshot())
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
stale_request = asyncio.create_task(main.live_snapshot())
await refresh_started.wait()
await asyncio.sleep(0)
returned_immediately = stale_request.done()
release_refresh.set()
stale = payload(await stale_request)
assert returned_immediately is True
assert first["context"]["generation"] == 1
assert stale["context"]["generation"] == 1
assert stale["freshness"]["stale"] is True
assert stale["freshness"]["revalidating"] is True
assert builds == 2
@pytest.mark.anyio
async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monkeypatch):
now = 100.0
builds = 0
async def snapshot():
nonlocal builds
builds += 1
if builds > 1:
raise ConnectionError("private upstream failure")
return {
"context": {"generation": 1},
"events": [{"id": 1}],
"notifications": [],
"sections": {
"context": "fresh",
"events": "fresh",
"notifications": "fresh",
},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
stale = payload(await main.live_snapshot())
await asyncio.sleep(0)
degraded = payload(await main.live_snapshot())
assert stale["freshness"]["revalidating"] is True
assert degraded["context"] == {"generation": 1}
assert degraded["freshness"]["stale"] is True
assert degraded["freshness"]["degraded"] is True
assert degraded["freshness"]["last_refresh_failed"] is True
assert degraded["freshness"]["revalidating"] is False
assert degraded["freshness"]["retry_in_seconds"] > 0
assert builds == 2
assert "private" not in json.dumps(degraded)
@pytest.mark.anyio
async def test_partial_refresh_updates_fresh_sections_and_retains_failed_sections(monkeypatch):
now = 100.0
builds = 0
async def snapshot():
nonlocal builds
builds += 1
if builds == 1:
return {
"context": {"generation": 1},
"events": [{"id": "last-known"}],
"notifications": [{"id": 7}],
"notification_pagination": {"page": 1, "total": 1, "has_more": False},
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
}
return {
"context": {"generation": 2},
"events": None,
"notifications": [{"id": 8}],
"notification_pagination": {"page": 1, "total": 1, "has_more": False},
"sections": {
"context": "fresh",
"events": "temporarily unavailable",
"notifications": "fresh",
},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
await main.live_snapshot()
await asyncio.sleep(0)
result = payload(await main.live_snapshot())
assert result["context"] == {"generation": 2}
assert result["events"] == [{"id": "last-known"}]
assert result["notifications"] == [{"id": 8}]
assert result["sections"] == {
"context": "fresh",
"events": "stale",
"notifications": "fresh",
}
assert result["freshness"]["degraded"] is True
assert result["freshness"]["revalidating"] is False
@pytest.mark.anyio
async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state(monkeypatch):
now = 100.0
builds = 0
retry_started = asyncio.Event()
release_retry = asyncio.Event()
async def snapshot():
nonlocal builds
builds += 1
if builds == 1:
return {
"context": {"generation": 1}, "events": [], "notifications": [],
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
}
if builds == 2:
return {
"context": None, "events": [], "notifications": [],
"sections": {
"context": "temporarily unavailable",
"events": "fresh",
"notifications": "fresh",
},
}
retry_started.set()
await release_retry.wait()
return {
"context": {"generation": 3}, "events": [], "notifications": [],
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
await main.live_snapshot()
await asyncio.sleep(0)
now += main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS
first = asyncio.create_task(main.live_snapshot())
second = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
await asyncio.sleep(0)
assert retry_started.is_set()
assert builds == 3
assert payload(await first)["freshness"]["revalidating"] is True
assert payload(await second)["freshness"]["revalidating"] is True
release_retry.set()
refresh_task = main._live_snapshot_task
assert refresh_task is not None
await refresh_task
recovered = payload(await main.live_snapshot())
assert recovered["context"] == {"generation": 3}
assert recovered["freshness"]["degraded"] is False
assert recovered["freshness"]["last_refresh_failed"] is False
assert recovered["freshness"]["retry_in_seconds"] == 0
@pytest.mark.anyio
async def test_cancelling_one_waiter_does_not_cancel_the_shared_snapshot(monkeypatch):
started = asyncio.Event()
release = asyncio.Event()
snapshot_calls = 0
async def blocked_snapshot():
nonlocal snapshot_calls
snapshot_calls += 1
started.set()
await release.wait()
return {
"context": {},
"events": [],
"notifications": [],
"sections": {},
}
monkeypatch.setattr(main, "_build_live_snapshot", blocked_snapshot)
disconnected = asyncio.create_task(main.live_snapshot())
await started.wait()
survivor = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
disconnected.cancel()
with pytest.raises(asyncio.CancelledError):
await disconnected
assert main._live_snapshot_task is not None
assert not main._live_snapshot_task.done()
release.set()
response = await survivor
assert response.status_code == 200
assert snapshot_calls == 1
@pytest.mark.anyio
async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(monkeypatch):
started = asyncio.Event()
cancelled = asyncio.Event()
async def snapshot_that_exceeds_deadline():
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(main, "_build_live_snapshot", snapshot_that_exceeds_deadline)
monkeypatch.setattr(main, "CONTEXT_TIMEOUT_SECONDS", 0.01)
first = asyncio.create_task(main.live_snapshot())
await started.wait()
second = asyncio.create_task(main.live_snapshot())
try:
responses = await asyncio.gather(first, second)
await asyncio.wait_for(cancelled.wait(), timeout=0.1)
assert [response.status_code for response in responses] == [503, 503]
assert [payload(response)["error"] for response in responses] == [
"Gitea live snapshot timed out after 0.01s",
"Gitea live snapshot timed out after 0.01s",
]
finally:
task = main._live_snapshot_task
if task is not None and not task.done():
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
+389 -15
View File
@@ -256,6 +256,293 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"] == [False, False]
def test_bulk_notification_acknowledger_deduplicates_and_keeps_partial_failures_retryable():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [
{{kind:'issue', key:'repo#1', notification_id:42, has_update:true}},
{{kind:'update', key:'repo#2', notification_id:43, has_update:true}},
{{kind:'update', key:'repo#3', notification_id:44, has_update:true}},
];
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = buildMyWork.createBulkNotificationAcknowledger({{
markRead: ids => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{marked:[42,43], failed:[44]}}); }});
}},
onItems: items => states.push(items),
onStatus: status => statuses.push(status),
}});
const first = controller.acknowledge(original, [42, 43, 42, 44]);
const duplicate = controller.acknowledge(original, [42, 43, 44]);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, states, statuses, results,
retryIds: buildMyWork.notificationIds(states.at(-1)),
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["results"] == [
{"marked": [42, 43], "failed": [44]},
False,
]
assert output["states"] == [[
{"kind": "issue", "key": "repo#1", "has_update": False},
{"kind": "update", "key": "repo#3", "notification_id": 44, "has_update": True},
]]
assert output["retryIds"] == [44]
assert output["statuses"] == [
"Marking 3 updates read…",
"2 marked read · 1 could not be updated — retry.",
]
def test_notification_pager_is_single_flight_and_merges_unique_updates():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let calls = 0;
let release;
const pages = [];
const notifications = [];
const statuses = [];
const pager = buildMyWork.createNotificationPager({{
load: page => {{
calls += 1;
return new Promise(resolve => {{ release = () => resolve({{
items: [{{id:50, title:'duplicate'}}, {{id:51, title:'older'}}],
page, total: 75, has_more: false,
}}); }});
}},
onNotifications: items => notifications.push(items),
onPagination: page => pages.push(page),
onStatus: status => statuses.push(status),
}});
pager.reset({{page:1, total:75, has_more:true}});
const existing = [{{id:50, title:'newer'}}];
const first = pager.loadMore(existing);
const duplicate = pager.loadMore(existing);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, pages, notifications, statuses, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 1
assert output["notifications"] == [[
{"id": 50, "title": "newer"},
{"id": 51, "title": "older"},
]]
assert output["pages"][-1] == {"page": 2, "total": 75, "has_more": False}
assert output["statuses"] == ["Loading older updates…", "75 of 75 unread updates loaded."]
assert output["results"] == [True, False]
def test_notification_pager_keeps_loaded_updates_and_retries_the_same_page_after_failure():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const states = [];
const statuses = [];
const requested = [];
const pager = buildMyWork.createNotificationPager({{
load: async page => {{ requested.push(page); throw new Error('offline'); }},
onNotifications: items => states.push(items),
onPagination: () => {{}},
onStatus: status => statuses.push(status),
}});
pager.reset({{page:2, total:125, has_more:true}});
pager.loadMore([{{id:1}}]).then(result =>
pager.loadMore([{{id:1}}]).then(retry =>
process.stdout.write(JSON.stringify({{requested, states, statuses, result, retry}}))
)
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"requested": [3, 3],
"states": [],
"statuses": [
"Loading older updates…", "Could not load older updates. Retry.",
"Loading older updates…", "Could not load older updates. Retry.",
],
"result": False,
"retry": False,
}
def test_notification_reader_marks_current_read_and_opens_next_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [
{{kind:'update', key:'repo#1', notification_id:42, has_update:true, title:'First'}},
{{kind:'issue', key:'repo#2', notification_id:43, has_update:true, title:'Second'}},
];
const loaded = [];
const opened = [];
const details = [];
const states = [];
const statuses = [];
const closed = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => {{ loaded.push(id); return {{id, latest_comment:{{body:'Comment ' + id}}}}; }},
markRead: async id => id,
onOpen: item => opened.push(item.notification_id),
onDetail: detail => details.push(detail.id),
onItems: items => states.push(items),
onStatus: status => statuses.push(status),
onClose: () => closed.push(true),
}});
reader.open(original[0], original).then(() =>
reader.markReadAndNext(original).then(result =>
process.stdout.write(JSON.stringify({{loaded, opened, details, states, statuses, closed, result}}))
)
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["loaded"] == [42, 43]
assert output["opened"] == [42, 43]
assert output["details"] == [42, 43]
assert output["states"] == [[
{"kind": "issue", "key": "repo#2", "notification_id": 43,
"has_update": True, "title": "Second"},
]]
assert output["statuses"] == [
"Loading update…", "Update ready.", "Marking update read…",
"Loading update…", "Update ready.",
]
assert output["closed"] == []
assert output["result"]["next"]["notification_id"] == 43
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const events = [];
const reader = buildMyWork.createNotificationReader({{
load: async () => {{ throw new Error('offline'); }},
markRead: async () => {{}}, onOpen: () => events.push('open'),
onDetail: () => events.push('detail'), onItems: () => events.push('items'),
onStatus: status => events.push(status), onClose: () => events.push('close'),
}});
reader.open(item, [item]).then(result =>
process.stdout.write(JSON.stringify({{result, events}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"result": False,
"events": [
"open", "Loading update…",
"Could not load update. Retry or open it in Gitea.",
],
}
def test_notification_reader_does_not_advance_or_remove_item_when_mark_read_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const events = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => ({{id}}), markRead: async () => {{ throw new Error('offline'); }},
onOpen: () => events.push('open'), onDetail: () => events.push('detail'),
onItems: () => events.push('items'), onStatus: status => events.push(status),
onClose: () => events.push('close'),
}});
reader.open(item, [item]).then(() =>
reader.markReadAndNext([item]).then(result =>
process.stdout.write(JSON.stringify({{result, events}}))
)
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"result": False,
"events": [
"open", "Loading update…", "detail", "Update ready.",
"Marking update read…", "Could not mark update read. Retry.",
],
}
def test_notification_reader_wraps_to_an_earlier_visible_unread_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'update', notification_id:42, has_update:true}},
{{kind:'update', notification_id:43, has_update:true}},
];
const opened = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => ({{id}}), markRead: async () => {{}},
onOpen: item => opened.push(item.notification_id), onDetail: () => {{}},
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
reader.open(items[1], items).then(() => reader.markReadAndNext(items)).then(result =>
process.stdout.write(JSON.stringify({{opened, next:result.next.notification_id}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"opened": [43, 42], "next": 42}
def test_notification_reader_closes_and_announces_when_final_update_is_cleared():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const events = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => ({{id}}), markRead: async () => {{}},
onOpen: () => {{}}, onDetail: () => {{}}, onItems: items => events.push(['items', items]),
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
reader.open(item, [item]).then(() => {{
events.length = 0;
return reader.markReadAndNext([item]);
}}).then(result => process.stdout.write(JSON.stringify({{result, events}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["result"] == {"items": [], "next": None}
assert output["events"] == [
["status", "Marking update read…"],
["items", []],
["close"],
["status", "Inbox cleared."],
]
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
@@ -288,6 +575,59 @@ async def test_unread_cards_offer_accessible_mobile_mark_read_without_nested_act
assert '<a class="my-work-card"' not in html
@pytest.mark.anyio
async def test_mobile_update_reader_is_in_app_safe_area_aware_and_actionable():
html = await dashboard()
assert 'id="update-sheet"' in html and 'aria-modal="true"' in html
assert 'class="read-update"' in html
assert 'id="keep-update-unread"' in html
assert 'id="mark-update-read-next"' in html
assert 'id="retry-update-load"' in html
assert 'id="update-comment-body"' in html
assert 'id="update-subject-body"' in html
assert 'id="open-update-gitea"' in html
assert '.update-sheet-panel { width:min(560px,100%);' in html
assert '.update-sheet-content { overflow-wrap:anywhere;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
assert "createNotificationReader" in html
assert "api/v1/notifications/" in html
assert "notificationReader.markReadAndNext(lastMyWork)" in html
@pytest.mark.anyio
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
html = await dashboard()
assert 'id="bulk-mark-read"' in html
assert 'id="bulk-mark-read-bar"' in html
assert 'class="my-work-bulk"' in html
assert '.my-work-bulk { position:sticky;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
assert "'Confirm marking ' + bulkLabel + ' read'" in html
assert "const ids = allIds.slice(0, 50)" in html
assert "createBulkNotificationAcknowledger" in html
assert "api/v1/notifications/read" in html
assert "body: JSON.stringify({ ids })" in html
@pytest.mark.anyio
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
html = await dashboard()
assert 'id="notification-page-status"' in html
assert 'id="load-more-notifications"' in html
assert '.load-more-notifications' in html and 'min-height:44px' in html
assert "createNotificationPager" in html
assert "api/v1/notifications?page=" in html
assert "snapshot.notification_pagination" in html
assert "notificationPager.loadMore(lastNotifications)" in html
assert "Math.min(lastNotifications.length, 50)" in html
@pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
html = await dashboard()
@@ -468,25 +808,29 @@ process.stdout.write(markdown);
)
def test_review_handoff_opens_gitea_only_after_copy_and_preserves_failed_copy():
def test_review_handoff_reserves_window_before_copy_and_reports_blocked_popup():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const events = [];
let finishCopy;
const reserved = {{ location: {{ href: '' }}, close: () => events.push('close') }};
const success = reviewSheet.copyAndContinue({{
text: 'feedback', url: 'https://forge.example/pulls/7',
copy: async text => events.push('copy:' + text),
open: url => events.push('open:' + url),
copy: text => new Promise(resolve => {{ events.push('copy:' + text); finishCopy = resolve; }}),
open: () => {{ events.push('reserve'); return reserved; }},
fallback: text => events.push('fallback:' + text),
}});
const failedEvents = [];
const failure = reviewSheet.copyAndContinue({{
text: 'keep me', url: 'https://forge.example/pulls/8',
copy: async () => {{ throw new Error('denied'); }},
open: url => failedEvents.push('open:' + url),
fallback: text => failedEvents.push('fallback:' + text),
const beforeCopySettles = events.slice();
finishCopy();
const blockedEvents = [];
const blocked = reviewSheet.copyAndContinue({{
text: 'copied', url: 'https://forge.example/pulls/8',
copy: async () => blockedEvents.push('copy'),
open: () => {{ blockedEvents.push('reserve'); return null; }},
fallback: text => blockedEvents.push('fallback:' + text),
}});
Promise.all([success, failure]).then(results => process.stdout.write(JSON.stringify({{
events, failedEvents, results
Promise.all([success, blocked]).then(results => process.stdout.write(JSON.stringify({{
beforeCopySettles, events, blockedEvents, destination: reserved.location.href, results
}})));
"""
result = subprocess.run(
@@ -494,9 +838,35 @@ Promise.all([success, failure]).then(results => process.stdout.write(JSON.string
)
assert json.loads(result.stdout) == {
"events": ["copy:feedback", "open:https://forge.example/pulls/7"],
"failedEvents": ["fallback:keep me"],
"results": [True, False],
"beforeCopySettles": ["reserve", "copy:feedback"],
"events": ["reserve", "copy:feedback"],
"blockedEvents": ["reserve", "copy", "fallback:copied"],
"destination": "https://forge.example/pulls/7",
"results": [
{"copied": True, "opened": True},
{"copied": True, "opened": False},
],
}
def test_review_handoff_closes_reserved_window_when_copy_fails():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const events = [];
reviewSheet.copyAndContinue({{
text: 'keep me', url: 'https://forge.example/pulls/8',
copy: async () => {{ throw new Error('denied'); }},
open: () => ({{ close: () => events.push('close') }}),
fallback: text => events.push('fallback:' + text),
}}).then(result => process.stdout.write(JSON.stringify({{ events, result }})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"events": ["close", "fallback:keep me"],
"result": {"copied": False, "opened": False},
}
@@ -545,6 +915,9 @@ async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
assert 'id="review-summary"' in html
assert 'id="copy-review-feedback"' in html
assert 'id="review-copy-fallback"' in html
assert 'id="review-handoff-link"' in html
assert '.review-handoff-link' in html and 'min-height:44px' in html
assert '.review-handoff-link[hidden]' in html and 'display:none' in html
assert '.review-note' in html and 'min-height:88px' in html
assert '.review-handoff' in html and 'position:sticky' in html
assert "createReviewController.createDraft" in html
@@ -554,7 +927,8 @@ async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
assert "createReviewController.formatFeedback" in html
assert "createReviewController.copyAndContinue" in html
assert "navigator.clipboard.writeText" in html
assert "window.open(url, '_blank', 'noopener,noreferrer')" in html
assert "window.open('about:blank', '_blank')" in html
assert "handoffWindow.opener = null" in html
@pytest.mark.anyio
+76
View File
@@ -0,0 +1,76 @@
import asyncio
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_notification_detail_api_is_bounded_and_never_cacheable(monkeypatch):
calls = []
async def detail(thread_id):
calls.append(thread_id)
return {
"id": thread_id,
"repository": "stackchain/api",
"title": "Retry failed deploy",
"subject_type": "Issue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"subject_body": "Deploy fails after three retries.",
"latest_comment": {
"author": "alexander",
"body": "Logs point to the worker timeout.",
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
}
monkeypatch.setattr(main, "notification_detail", detail, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/42")
invalid = await client.get("/api/v1/notifications/0")
assert response.status_code == 200
assert response.json()["latest_comment"]["author"] == "alexander"
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert calls == [42]
@pytest.mark.anyio
async def test_notification_detail_timeout_is_sanitized_and_retryable(monkeypatch):
async def detail(_thread_id):
await asyncio.sleep(0.05)
monkeypatch.setattr(main, "notification_detail", detail)
monkeypatch.setattr(main, "NOTIFICATION_DETAIL_TIMEOUT_SECONDS", 0.01)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/42")
assert response.status_code == 503
assert response.json() == {"error": "Loading the update timed out. Please retry."}
assert response.headers["retry-after"] == "1"
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_notification_detail_upstream_failure_does_not_leak_exception(monkeypatch):
async def detail(_thread_id):
raise httpx.HTTPError("token=secret upstream exploded")
monkeypatch.setattr(main, "notification_detail", detail)
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/42")
assert response.status_code == 503
assert response.json() == {
"error": "The update is temporarily unavailable. Please retry."
}
assert "secret" not in response.text
assert response.headers["retry-after"] == "1"
+33
View File
@@ -0,0 +1,33 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_cacheable(monkeypatch):
requested = []
async def page_loader(page):
requested.append(page)
return {
"items": [{"id": 51, "title": "Older update"}],
"page": page,
"total": 125,
"has_more": True,
}
monkeypatch.setattr(main.gitea_proxy, "notification_page", page_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications?page=2")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [{"id": 51, "title": "Older update"}],
"page": 2,
"total": 125,
"has_more": True,
}
assert requested == [2]
+194
View File
@@ -1,3 +1,6 @@
import asyncio
import time
import httpx
import pytest
@@ -45,3 +48,194 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert marked == [42]
@pytest.mark.anyio
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
calls = []
async def mark(thread_id):
calls.append(thread_id)
if thread_id == 43:
raise httpx.HTTPError("upstream unavailable")
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/notifications/read", json={"ids": [42, 43, 42, 44]}
)
assert response.status_code == 200
assert response.json() == {"marked": [42, 44], "failed": [43]}
assert response.headers["cache-control"] == "no-store"
assert sorted(calls) == [42, 43, 44]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_limits_upstream_concurrency(monkeypatch):
active = 0
peak_active = 0
started = asyncio.Event()
release = asyncio.Event()
calls = []
monkeypatch.setattr(main, "_read_notification_ids", set())
monkeypatch.setattr(main, "_live_snapshot_value", None)
async def mark(thread_id):
nonlocal active, peak_active
calls.append(thread_id)
active += 1
peak_active = max(peak_active, active)
if active == 5:
started.set()
try:
await release.wait()
finally:
active -= 1
monkeypatch.setattr(main, "mark_notification_read", mark)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
request = asyncio.create_task(
client.patch("/api/v1/notifications/read", json={"ids": list(range(1, 51))})
)
await asyncio.wait_for(started.wait(), timeout=1)
await asyncio.sleep(0)
assert peak_active == 5
assert len(calls) == 5
release.set()
response = await request
assert response.status_code == 200
assert response.json() == {"marked": list(range(1, 51)), "failed": []}
assert sorted(calls) == list(range(1, 51))
@pytest.mark.anyio
async def test_bulk_mark_read_cancels_unfinished_work_at_batch_deadline(monkeypatch):
active = 0
cancelled = []
async def mark(thread_id):
nonlocal active
active += 1
try:
await asyncio.sleep(0.08)
except asyncio.CancelledError:
cancelled.append(thread_id)
raise
finally:
active -= 1
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(main, "BULK_NOTIFICATION_DEADLINE_SECONDS", 0.01, raising=False)
monkeypatch.setattr(main, "_read_notification_ids", set())
monkeypatch.setattr(main, "_live_snapshot_value", None)
transport = httpx.ASGITransport(app=main.app)
started_at = time.monotonic()
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
)
elapsed = time.monotonic() - started_at
assert elapsed < 0.06
assert response.status_code == 200
assert response.json() == {"marked": [], "failed": list(range(1, 11))}
assert response.headers["retry-after"] == "1"
assert active == 0
assert sorted(cancelled) == list(range(1, 6))
@pytest.mark.anyio
async def test_bulk_mark_read_rejects_empty_invalid_and_oversized_batches(monkeypatch):
marked = []
async def mark(thread_id):
marked.append(thread_id)
monkeypatch.setattr(main, "mark_notification_read", mark)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
empty = await client.patch("/api/v1/notifications/read", json={"ids": []})
invalid = await client.patch(
"/api/v1/notifications/read", json={"ids": [42, 0]}
)
oversized = await client.patch(
"/api/v1/notifications/read", json={"ids": list(range(1, 52))}
)
assert [empty.status_code, invalid.status_code, oversized.status_code] == [
422,
422,
422,
]
assert all(response.headers["cache-control"] == "no-store" for response in [empty, invalid, oversized])
assert marked == []
@pytest.mark.anyio
async def test_mark_notification_read_removes_thread_from_retained_live_snapshot(monkeypatch):
async def mark(_thread_id):
return None
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(
main,
"_live_snapshot_value",
{
"context": {},
"events": [],
"notifications": [{"id": 42}, {"id": 43}],
"sections": {"notifications": "fresh"},
},
)
response = await main.read_notification(42)
assert response.status_code == 200
assert main._live_snapshot_value is not None
assert main._live_snapshot_value["notifications"] == [{"id": 43}]
@pytest.mark.anyio
async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkeypatch):
refresh_started = asyncio.Event()
release_refresh = asyncio.Event()
async def mark(_thread_id):
return None
async def stale_upstream_snapshot():
refresh_started.set()
await release_refresh.wait()
return {
"context": {},
"events": [],
"notifications": [{"id": 42}, {"id": 43}],
"sections": {"notifications": "fresh"},
}
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(
main, "_build_live_snapshot_before_deadline", stale_upstream_snapshot
)
monkeypatch.setattr(main, "_live_snapshot_task", None)
monkeypatch.setattr(main, "_live_snapshot_value", {"notifications": [{"id": 42}]})
refresh = main._start_live_snapshot_refresh()
await refresh_started.wait()
await main.read_notification(42)
release_refresh.set()
await refresh
assert main._live_snapshot_value is not None
assert main._live_snapshot_value["notifications"] == [{"id": 43}]
+18 -1
View File
@@ -26,4 +26,21 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
assert "renderContextSnapshot(snapshot.context)" in html
assert "paintEventStream(snapshot.events)" in html
assert "loadEventStream" not in html
assert "5000" not in html
assert "5000" not in html
@pytest.mark.anyio
async def test_dashboard_announces_when_recent_snapshot_is_revalidating():
html = await dashboard()
assert "snapshot.freshness?.revalidating" in html
assert "Refreshing · showing recent snapshot" in html
@pytest.mark.anyio
async def test_dashboard_announces_failed_refresh_and_retry_without_blanking_panels():
html = await dashboard()
assert "snapshot.freshness?.degraded" in html
assert "Refresh failed · showing last known data" in html
assert "snapshot.freshness.retry_in_seconds" in html