Compare commits

...
Author SHA1 Message Date
timmy 6e900e31e3 feat: acknowledge unread updates from My Work (#135)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 20:30:35 +00:00
rockachopa 678005ece9 Merge pull request 'Add unread Gitea updates to the mobile My Work inbox' (#134) from timmy/133-mobile-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 20:00:25 +00:00
timmy f34fc8b5f5 feat: add unread updates to My Work (#133)
CI / lint (pull_request) Successful in 12s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 19:59:39 +00:00
rockachopa d75a55f3f7 Merge pull request 'Unify live dashboard polling with a pooled Gitea snapshot' (#132) from timmy/131-unified-live-snapshot into main
CI / lint (push) Successful in 11s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Merge pull request 'Unify live dashboard polling with a pooled Gitea snapshot' (#132) from timmy/131-unified-live-snapshot into main
2026-08-06 19:30:03 +00:00
timmy 30f92d18a2 feat: unify live dashboard snapshot (#131)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 19:29:21 +00:00
timmy e2a4b60143 Merge pull request 'Carry mobile review feedback into the authenticated Gitea handoff' (#130) from timmy/129-mobile-review-feedback-handoff 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 18:57:14 +00:00
timmy 331eb99c1e feat: carry mobile review feedback into Gitea (#129)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 18:56:06 +00:00
timmy bd0ae619db Merge pull request 'Make mobile pull review loading bounded, concurrent, and retryable' (#128) from timmy/127-review-load-retry 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 18:25:53 +00:00
timmy 173af9f698 feat: make mobile review loading retryable (#127)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 18:25:10 +00:00
rockachopa 3bf635fb89 Merge pull request 'Resume mobile pull review progress across interruptions' (#126)
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #125
2026-08-06 17:55:21 +00:00
timmy d8937f0096 feat: resume mobile review progress (#125)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 17:54:35 +00:00
timmy c4a9c76781 Merge pull request 'Preview requested pull request diffs on mobile' (#124)
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Closes #123
2026-08-06 17:32:26 +00:00
17 changed files with 1580 additions and 169 deletions

No files matched your search

+2 -1
View File
@@ -14,7 +14,8 @@ python3 -m pip install -r requirements.txt
```
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
read-scoped access token, then start the API and bundled frontend:
token that can read dashboard data and update the authenticated user's
notification threads, then start the API and bundled frontend:
```bash
export GITEA_URL='https://forge.example.com'
+228 -24
View File
@@ -60,10 +60,12 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter { min-height: 44px; }
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.my-work-card { min-height: 44px; display:block; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.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%; }
.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; }
@@ -73,6 +75,19 @@ textarea { resize: vertical; min-height: 120px; }
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
.review-file-toggle strong { overflow-wrap:anywhere; }
.review-file.reviewed { opacity:.72; }
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
.review-mark { min-height:44px; width:100%; margin-top:8px; }
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
.review-note { min-height:88px; margin-top:6px; }
.review-feedback { display:grid; gap:8px; }
.review-feedback label { display:grid; gap:6px; }
.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-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%; }
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
.review-diff-line { display:block; min-width:max-content; }
.review-diff-line.hunk { color:#93c5fd; }
@@ -80,6 +95,7 @@ textarea { resize: vertical; min-height: 120px; }
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
.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; }
@media (max-width: 600px) {
header { align-items:flex-start; }
.my-work { margin:0; }
@@ -117,9 +133,11 @@ textarea { resize: vertical; min-height: 120px; }
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
<button class="work-filter" data-work-filter="update" aria-pressed="false">Updates <span data-work-count="update">0</span></button>
</div>
</div>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
</section>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
@@ -216,10 +234,33 @@ textarea { resize: vertical; min-height: 120px; }
<button class="review-action" id="close-review-sheet">Close</button>
</div>
<div id="review-sheet-status" class="small" aria-live="polite">Choose a review request.</div>
<button class="review-retry" id="retry-review-load" hidden>Retry loading review</button>
<p class="review-sheet-body" id="review-sheet-body"></p>
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
<h2>Changed files</h2>
<div id="review-files" class="muted"></div>
<div class="review-progress-actions">
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
</div>
<section class="review-feedback" aria-labelledby="review-feedback-title">
<h2 id="review-feedback-title">Draft feedback</h2>
<label>Intended decision
<select id="review-decision">
<option value="comment">Comment</option>
<option value="approve">Approve</option>
<option value="request_changes">Request changes</option>
</select>
</label>
<label>Overall summary
<textarea id="review-summary" placeholder="Summarize your review"></textarea>
</label>
<div class="review-handoff">
<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>
</div>
</section>
<h2>Review history</h2>
<div id="review-history" class="muted"></div>
</section>
@@ -263,14 +304,18 @@ textarea { resize: vertical; min-height: 120px; }
let selectedWorkFilter = 'all';
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'issue', 'pull', 'review'].includes(savedFilter)) selectedWorkFilter = savedFilter;
if (['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)) selectedWorkFilter = savedFilter;
} catch (e) {
console.warn('Could not restore My Work filter', e);
}
let lastMyWork = [];
let lastNotifications = [];
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
let progress = null;
let draft = null;
let reviewFiles = [];
async function fetchReviewJson(url, options) {
const response = await fetch(url, options);
@@ -284,12 +329,31 @@ textarea { resize: vertical; min-height: 120px; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
setClock(); setInterval(setClock, 1000);
async function fetchContextSnapshot() {
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
async function fetchLiveSnapshot() {
const res = await fetch('api/v1/live', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
async function markNotificationRead(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/read', {
method: 'PATCH',
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Mark read failed.');
return payload;
}
const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
@@ -334,6 +398,10 @@ textarea { resize: vertical; min-height: 120px; }
function paintMyWork(data) {
lastMyWork = buildMyWork(data);
refreshMyWorkView();
}
function refreshMyWorkView() {
const counts = countMyWork(lastMyWork);
Object.entries(counts).forEach(([filter, count]) => {
const element = qs('[data-work-count="' + filter + '"]');
@@ -341,7 +409,7 @@ textarea { resize: vertical; min-height: 120px; }
});
qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ?
summarizeMyWork(lastMyWork) : 'No assigned work or review requests.';
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
renderMyWork();
}
@@ -349,19 +417,68 @@ textarea { resize: vertical; min-height: 120px; }
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const contents =
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '</span>' +
'<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>' +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
(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>' : '';
if (item.is_review) {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
return '<button class="my-work-card review-trigger" data-review-index="' + index + '">' + contents + '</button>';
return '<article class="my-work-card"><button class="my-work-card-main review-trigger" data-review-index="' + index + '">' + contents + '</button>' + markRead + '</article>';
}
return '<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>';
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
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>';
}).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-notification-id]').forEach(button => {
button.addEventListener('click', async () => {
const notificationId = Number(button.dataset.notificationId);
button.disabled = true;
const acknowledged = await notificationAcknowledger.acknowledge(lastMyWork, notificationId);
if (acknowledged) {
lastNotifications = lastNotifications.filter(item => item.id !== notificationId);
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
} else {
document.querySelector('[data-notification-id="' + notificationId + '"]')?.focus();
}
});
});
}
function reviewFileElement(filename) {
return Array.from(document.querySelectorAll('.review-file')).find(
element => element.dataset.reviewFilename === filename
);
}
function showReviewProgress(snapshot) {
const complete = snapshot.total > 0 && snapshot.reviewedCount === snapshot.total;
qs('#review-progress').textContent = complete ?
'All files reviewed — finish in Gitea' :
(snapshot.newHead ? 'New commits detected · ' : '') + snapshot.reviewedCount + ' of ' + snapshot.total + ' files reviewed';
qs('#next-unreviewed-review').disabled = !snapshot.nextFilename;
document.querySelectorAll('.review-mark').forEach(button => {
const reviewed = snapshot.reviewed.includes(button.dataset.reviewFilename);
button.setAttribute('aria-pressed', String(reviewed));
button.textContent = reviewed ? 'Reviewed' : 'Mark reviewed';
button.closest('.review-file')?.classList.toggle('reviewed', reviewed);
});
}
function openNextUnreviewed(snapshot) {
if (!snapshot?.nextFilename) return;
const file = reviewFileElement(snapshot.nextFilename);
if (!file) return;
const toggle = file.querySelector('.review-file-toggle');
const panel = toggle && document.getElementById(toggle.getAttribute('aria-controls'));
if (toggle && panel && toggle.getAttribute('aria-expanded') !== 'true') {
createReviewController.toggleDiff(toggle, panel);
}
file.scrollIntoView({ behavior: 'smooth', block: 'center' });
toggle?.focus();
}
async function openReviewSheet(item, trigger) {
@@ -371,10 +488,20 @@ textarea { resize: vertical; min-height: 120px; }
qs('#review-sheet-key').textContent = item.key;
qs('#review-sheet-title').textContent = item.title;
qs('#review-sheet-status').textContent = 'Loading review details…';
qs('#retry-review-load').hidden = true;
qs('#review-sheet-body').textContent = '';
qs('#review-files').textContent = '';
qs('#review-progress').textContent = '0 of 0 files reviewed';
qs('#next-unreviewed-review').disabled = true;
qs('#review-history').textContent = '';
qs('#open-review-gitea').href = item.url;
qs('#review-decision').value = 'comment';
qs('#review-summary').value = '';
qs('#review-copy-fallback').hidden = true;
qs('#review-copy-fallback').value = '';
qs('#review-handoff-status').textContent = '';
draft = null;
reviewFiles = [];
qs('#close-review-sheet').focus();
try {
const detail = await reviewController.load(selectedReview);
@@ -390,19 +517,57 @@ textarea { resize: vertical; min-height: 120px; }
if (panel) createReviewController.toggleDiff(button, panel);
});
});
progress = createReviewController.createProgress({
storage: localStorage,
repository: item.repository,
number: item.number,
headSha: detail.head_sha || 'unknown',
files: detail.files || [],
});
reviewFiles = detail.files || [];
draft = createReviewController.createDraft({
storage: localStorage,
repository: item.repository,
number: item.number,
headSha: detail.head_sha || 'unknown',
files: reviewFiles,
});
const draftSnapshot = draft.snapshot();
qs('#review-decision').value = draftSnapshot.decision;
qs('#review-summary').value = draftSnapshot.summary;
document.querySelectorAll('.review-note').forEach(note => {
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
});
document.querySelectorAll('.review-mark').forEach(button => {
button.addEventListener('click', () => {
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
showReviewProgress(snapshot);
openNextUnreviewed(snapshot);
});
});
const progressSnapshot = progress.snapshot();
showReviewProgress(progressSnapshot);
openNextUnreviewed(progressSnapshot);
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small">' + escapeHtml(review.body) + '</div>' : '') + '</div>'
).join('') : '<div>No prior reviews.</div>';
qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author');
} catch (error) {
qs('#review-sheet-status').textContent = error.message + ' Use Open in Gitea or close and retry.';
if (selectedReview !== item) return;
qs('#review-sheet-status').textContent = error.message + ' Retry here or use Open in Gitea.';
qs('#retry-review-load').hidden = false;
qs('#retry-review-load').focus();
}
}
function closeReviewSheet() {
qs('#review-sheet').classList.remove('open');
selectedReview = null;
progress = null;
draft = null;
reviewFiles = [];
if (reviewTrigger?.isConnected) reviewTrigger.focus();
}
@@ -412,6 +577,13 @@ textarea { resize: vertical; min-height: 120px; }
'Update failed · showing last known work' : 'Work inbox unavailable.';
}
function markNotificationsStale() {
qs('#my-work').setAttribute('data-stale', 'true');
qs('#my-work-status').textContent = lastNotifications.length ?
'Unread updates unavailable · showing last known updates' :
'Unread updates unavailable · assigned work is fresh';
}
function paintDeltas(deltas) {
const el = qs('#ai');
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
@@ -431,14 +603,18 @@ textarea { resize: vertical; min-height: 120px; }
qs('#gitea-events-status').textContent = message;
}
async function loadEventStream() {
setEventStreamStatus('Updating…');
try {
const res = await fetch('api/v1/events', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
paintEventStream(await res.json());
function renderLiveSnapshot(snapshot) {
const notificationsFresh = Array.isArray(snapshot.notifications);
if (notificationsFresh) lastNotifications = snapshot.notifications;
if (snapshot.context) {
snapshot.context.notifications = lastNotifications;
renderContextSnapshot(snapshot.context);
if (!notificationsFresh) markNotificationsStale();
} else handleContextError(new Error('Context section unavailable'));
if (snapshot.events) {
paintEventStream(snapshot.events);
setEventStreamStatus('Updated ' + fmt(new Date()));
} catch (e) {
} else {
setEventStreamStatus('Update failed · showing last activity');
}
}
@@ -513,11 +689,42 @@ textarea { resize: vertical; min-height: 120px; }
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('#close-review-sheet').addEventListener('click', closeReviewSheet);
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
});
qs('#next-unreviewed-review').addEventListener('click', () => {
if (progress) openNextUnreviewed(progress.snapshot());
});
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;
const fallback = qs('#review-copy-fallback');
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.';
});
const contextPoller = createContextPoller({
fetchContext: fetchContextSnapshot,
onSnapshot: renderContextSnapshot,
onError: handleContextError,
fetchContext: fetchLiveSnapshot,
onSnapshot: renderLiveSnapshot,
onError: error => {
handleContextError(error);
setEventStreamStatus('Update failed · showing last activity');
},
isHidden: () => document.hidden,
intervalMs: 8000,
});
@@ -540,11 +747,8 @@ textarea { resize: vertical; min-height: 120px; }
});
});
contextPoller.start();
loadEventStream();
setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000);
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
if (!document.hidden) loadEventStream();
});
/* Widgets */
+74 -4
View File
@@ -4,7 +4,7 @@ function buildMyWork(data) {
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
const priorityLabels = ['p0', 'priority-high', 'critical'];
return issues.concat(pulls).map((item) => {
const work = issues.concat(pulls).map((item) => {
const labels = item.labels || [];
const priorityLabel = labels.find((label) =>
priorityLabels.includes(String(label).toLowerCase())
@@ -16,29 +16,96 @@ function buildMyWork(data) {
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
is_assigned: assigned,
has_update: false,
reason: priorityLabel ? priorityLabel + ' priority' :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
_priority: priorityLabel ? 0 : (isReview ? 1 : (assigned ? 2 : 3)),
_priority: priorityLabel ? 0 : (isReview ? 2 : (assigned ? 3 : 4)),
};
}).sort((left, right) =>
});
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
(data.notifications || []).filter((item) => item && item.unread).forEach((update) => {
const key = (update.repository || 'unknown') + '#' + update.number;
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
const existing = byKey.get(subjectKind + ':' + key);
if (existing) {
existing.has_update = true;
existing.notification_id = update.id;
existing.url = update.url || existing.url;
existing.updated_at = update.updated_at || existing.updated_at;
existing._priority = Math.min(existing._priority, 1);
return;
}
if (!update.url) return;
const item = {
...update,
key,
kind: 'update',
is_review: false,
is_assigned: false,
has_update: true,
notification_id: update.id,
reason: 'Unread update',
_priority: 1,
};
work.push(item);
byKey.set(subjectKind + ':' + key, item);
});
return work.sort((left, right) =>
left._priority - right._priority ||
String(right.updated_at || '').localeCompare(String(left.updated_at || '')) ||
left.key.localeCompare(right.key)
).map(({ _priority, ...item }) => item);
}
function acknowledgeNotification(items, notificationId) {
return items.flatMap((item) => {
if (item.notification_id !== notificationId) return [item];
if (item.kind === 'update') return [];
const { notification_id, ...acknowledged } = item;
return [{ ...acknowledged, has_update: false }];
});
}
function createNotificationAcknowledger({ markRead, onItems, onStatus }) {
const pending = new Set();
return {
async acknowledge(items, notificationId) {
if (pending.has(notificationId)) return false;
pending.add(notificationId);
onItems(acknowledgeNotification(items, notificationId));
onStatus('Marking update read…');
try {
await markRead(notificationId);
onStatus('Update marked read.');
return true;
} catch (_error) {
onItems(items);
onStatus('Could not mark update read. Retry.');
return false;
} finally {
pending.delete(notificationId);
}
},
};
}
function filterMyWork(items, selectedFilter) {
if (selectedFilter === 'all') return items;
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
if (selectedFilter === 'update') return items.filter((item) => item.has_update);
return items.filter((item) => item.kind === selectedFilter);
}
function summarizeMyWork(items) {
const updates = items.filter((item) => item.has_update).length;
const reviews = items.filter((item) => item.is_review).length;
const assigned = items.filter((item) => item.is_assigned).length;
const updateLabel = updates + ' unread update' + (updates === 1 ? '' : 's');
const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's');
const assignedLabel = assigned + ' assigned';
return reviewLabel + ' · ' + assignedLabel;
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
}
function countMyWork(items) {
@@ -47,6 +114,7 @@ function countMyWork(items) {
issue: items.filter((item) => item.kind === 'issue').length,
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
review: items.filter((item) => item.is_review).length,
update: items.filter((item) => item.has_update).length,
};
}
@@ -54,5 +122,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
module.exports = buildMyWork;
}
+133 -3
View File
@@ -38,10 +38,14 @@ function renderDiffFile(file, index, escapeHtml) {
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
}
return '<div class="review-file"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
'<strong>' + escapeHtml(file.filename || 'Unknown file') + '</strong><span class="small">' +
const filename = escapeHtml(file.filename || 'Unknown file');
return '<div class="review-file" data-review-filename="' + filename + '"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
'<strong>' + filename + '</strong><span class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' +
Number(file.deletions || 0) + '</span></button>' + preview + '</div>';
Number(file.deletions || 0) + '</span></button>' + preview +
'<label class="small" for="review-note-' + index + '">Note for ' + filename + '</label>' +
'<textarea class="review-note" id="review-note-' + index + '" data-review-filename="' + filename + '" placeholder="Capture feedback for this file"></textarea>' +
'<button class="review-mark" data-review-filename="' + filename + '" aria-pressed="false">Mark reviewed</button></div>';
}
function toggleDiff(button, panel) {
@@ -50,8 +54,134 @@ function toggleDiff(button, panel) {
panel.hidden = expanded;
}
function createProgress({ storage, repository, number, headSha, files }) {
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
const key = 'stackchain.review-progress.v1:' + repository + '#' + number + '@' + headSha;
const headKey = 'stackchain.review-progress.v1:' + repository + '#' + number + ':head';
let reviewed = [];
let newHead = false;
try {
const previousHead = storage.getItem(headKey);
newHead = Boolean(previousHead && previousHead !== headSha);
storage.setItem(headKey, headSha);
const saved = JSON.parse(storage.getItem(key) || '[]');
if (Array.isArray(saved)) reviewed = filenames.filter(filename => saved.includes(filename));
} catch (error) {
reviewed = [];
}
function snapshot() {
const pending = filenames.find(filename => !reviewed.includes(filename)) || null;
return {
reviewed: [...reviewed],
reviewedCount: reviewed.length,
total: filenames.length,
nextFilename: pending,
...(newHead ? { newHead: true } : {}),
};
}
function markReviewed(filename) {
if (filenames.includes(filename) && !reviewed.includes(filename)) {
reviewed.push(filename);
reviewed = filenames.filter(item => reviewed.includes(item));
try { storage.setItem(key, JSON.stringify(reviewed)); } catch (error) { /* local progress remains usable */ }
}
return snapshot();
}
return { snapshot, markReviewed };
}
function createDraft({ storage, repository, number, headSha, files }) {
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
let draft = { notes: {}, summary: '', decision: 'comment' };
try {
const saved = JSON.parse(storage.getItem(key) || '{}');
if (saved && typeof saved === 'object') {
draft.notes = Object.fromEntries(filenames
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
.map(filename => [filename, saved.notes[filename]]));
if (typeof saved.summary === 'string') draft.summary = saved.summary;
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) {
draft.decision = saved.decision;
}
}
} catch (error) { /* start with an empty in-memory draft */ }
function persist() {
try { storage.setItem(key, JSON.stringify(draft)); } catch (error) { /* draft remains usable */ }
}
function snapshot() {
return { notes: { ...draft.notes }, summary: draft.summary, decision: draft.decision };
}
function setNote(filename, note) {
if (!filenames.includes(filename)) return snapshot();
if (String(note)) draft.notes[filename] = String(note);
else delete draft.notes[filename];
persist();
return snapshot();
}
function setSummary(summary) {
draft.summary = String(summary || '');
persist();
return snapshot();
}
function setDecision(decision) {
if (['comment', 'approve', 'request_changes'].includes(decision)) {
draft.decision = decision;
persist();
}
return snapshot();
}
return { snapshot, setNote, setSummary, setDecision };
}
function formatFeedback(draft, files) {
const decisions = {
comment: 'Comment',
approve: 'Approve',
request_changes: 'Request changes',
};
const sections = [
'## Intended decision\n' + (decisions[draft.decision] || decisions.comment),
];
if (String(draft.summary || '').trim()) {
sections.push('## Summary\n' + String(draft.summary).trim());
}
const fileNotes = (files || []).flatMap(file => {
const filename = file && file.filename;
const note = filename && String(draft.notes?.[filename] || '').trim();
if (!note) return [];
return ['### `' + String(filename).replaceAll('`', '\\`') + '`\n' + note];
});
if (fileNotes.length) sections.push('## File notes\n' + fileNotes.join('\n\n'));
return sections.join('\n\n');
}
async function copyAndContinue({ text, url, copy, open, fallback }) {
try {
await copy(text);
} catch (error) {
fallback(text);
return false;
}
open(url);
return true;
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
createReviewController.createDraft = createDraft;
createReviewController.formatFeedback = formatFeedback;
createReviewController.copyAndContinue = copyAndContinue;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
+129 -32
View File
@@ -1,6 +1,8 @@
import asyncio
import os
import shlex
from typing import Any
from urllib.parse import urlsplit
import httpx
@@ -8,6 +10,7 @@ GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
REVIEW_DIFF_MAX_BYTES = 64 * 1024
REVIEW_DIFF_MAX_LINES = 400
_client: httpx.AsyncClient | None = None
def _auth() -> dict[str, str]:
@@ -17,30 +20,47 @@ def _auth() -> dict[str, str]:
return headers
def start_client(**kwargs) -> httpx.AsyncClient:
"""Create the application-lifetime Gitea transport."""
global _client
_client = httpx.AsyncClient(base_url=GITEA_URL, timeout=10, **kwargs)
return _client
def _get_client() -> httpx.AsyncClient:
if _client is None or _client.is_closed:
return start_client()
return _client
async def stop_client() -> None:
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
async def fetch(path: str) -> Any:
async with httpx.AsyncClient(base_url=GITEA_URL, timeout=10) as client:
r = await client.get(f"/api/v1/{path}", headers=_auth())
r.raise_for_status()
return r.json()
r = await _get_client().get(f"/api/v1/{path}", headers=_auth())
r.raise_for_status()
return r.json()
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
chunks: list[bytes] = []
size = 0
truncated = False
async with httpx.AsyncClient(base_url=GITEA_URL, timeout=10) as client:
async with client.stream(
"GET", f"/api/v1/{path}", headers={**_auth(), "Accept": "text/plain"}
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
remaining = max_bytes - size
if len(chunk) > remaining:
chunks.append(chunk[:remaining])
truncated = True
break
chunks.append(chunk)
size += len(chunk)
async with _get_client().stream(
"GET", f"/api/v1/{path}", headers={**_auth(), "Accept": "text/plain"}
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
remaining = max_bytes - size
if len(chunk) > remaining:
chunks.append(chunk[:remaining])
truncated = True
break
chunks.append(chunk)
size += len(chunk)
return b"".join(chunks).decode("utf-8", errors="replace"), truncated
@@ -98,12 +118,83 @@ async def issues() -> list[dict]:
)
async def pull_requests() -> list[dict]:
assigned = await fetch(
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50"
def _safe_web_url(value: Any) -> str:
if not isinstance(value, str):
return ""
parsed = urlsplit(value)
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")
if not isinstance(threads, list):
raise ValueError("Gitea notification response was not a list")
normalized = []
for thread in threads:
if not isinstance(thread, dict):
continue
repository = thread.get("repository")
subject = thread.get("subject")
repository = repository if isinstance(repository, dict) else {}
subject = subject if isinstance(subject, dict) else {}
subject_url = _safe_web_url(subject.get("html_url"))
latest_url = _safe_web_url(subject.get("latest_comment_html_url"))
number_text = (
urlsplit(subject_url).path.rstrip("/").rsplit("/", 1)[-1]
if subject_url
else ""
)
normalized.append(
{
"id": thread.get("id"),
"unread": thread.get("unread") is True,
"updated_at": (
thread.get("updated_at")
if isinstance(thread.get("updated_at"), str)
else ""
),
"repository": (
repository.get("full_name")
if isinstance(repository.get("full_name"), str)
else ""
),
"number": int(number_text) if number_text.isdigit() else None,
"title": (
subject.get("title")
if isinstance(subject.get("title"), str) and subject.get("title")
else "Untitled update"
),
"subject_type": (
subject.get("type")
if isinstance(subject.get("type"), str) and subject.get("type")
else "Update"
),
"state": (
subject.get("state")
if isinstance(subject.get("state"), str)
else ""
),
"url": latest_url or subject_url,
"subject_url": subject_url,
}
)
return normalized
async def mark_notification_read(thread_id: int) -> None:
response = await _get_client().patch(
f"/api/v1/notifications/threads/{thread_id}?to-status=read",
headers=_auth(),
)
review_requested = await fetch(
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
response.raise_for_status()
async def pull_requests() -> list[dict]:
assigned, review_requested = await asyncio.gather(
fetch("repos/issues/search?state=open&assigned=true&type=pulls&limit=50"),
fetch(
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
),
)
merged: dict[int, dict] = {}
for reason, pulls in (
@@ -119,14 +210,15 @@ async def pull_requests() -> list[dict]:
async def is_requested_review(repository: str, number: int) -> bool:
pulls = await pull_requests()
pulls = await fetch(
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50"
)
return any(
isinstance(pull, dict)
and pull.get("number") == number
and "review_requested" in (pull.get("work_reasons") or [])
and isinstance(pull.get("repository"), dict)
and pull["repository"].get("full_name") == repository
for pull in pulls
for pull in (pulls or [])
)
@@ -135,16 +227,19 @@ async def pull_review_detail(repository: str, number: int) -> dict:
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
files = await fetch(f"{base}/files")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
status = await fetch(f"repos/{repository}/commits/{sha}/status")
reviews = await fetch(f"{base}/reviews")
diff, diff_truncated = await fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
files, status, reviews, diff_result = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"{base}/reviews"),
fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
),
)
diff, diff_truncated = diff_result
previews = _diff_previews(diff, diff_truncated)
user_value = pull.get("user")
user: dict = user_value if isinstance(user_value, dict) else {}
@@ -189,14 +284,16 @@ async def pull_review_detail(repository: str, number: int) -> dict:
"body": pull.get("body") or "",
"url": pull.get("html_url", ""),
"author": user.get("login", ""),
"head_sha": sha,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"files": normalized_files,
"reviews": normalized_reviews,
}
async def activity_events() -> list[dict]:
user = await current_user()
async def activity_events(user: dict | None = None) -> list[dict]:
if user is None:
user = await current_user()
events = await fetch(f"users/{user['login']}/activities/feeds?limit=20")
if events is None:
events = []
+169 -89
View File
@@ -1,17 +1,21 @@
import asyncio
import math
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Path as PathParam
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from src import gitea_proxy
from src.gitea_proxy import (
activity_events,
current_user,
is_requested_review,
issues,
mark_notification_read,
notifications,
pull_requests,
pull_review_detail,
repos,
@@ -20,11 +24,23 @@ from src.models import Issue, PullRequest, Repo, User
from src.suggestion_engine import compute
from src.views import router as frontend_router
app = FastAPI(title="Stackchain Dashboard")
@asynccontextmanager
async def lifespan(_app: FastAPI):
gitea_proxy.start_client()
try:
yield
finally:
await gitea_proxy.stop_client()
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
class ContextPayloadError(ValueError):
@@ -34,6 +50,52 @@ class ContextPayloadError(ValueError):
class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload."""
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user_model = User(
id=user_data["id"],
login=user_data["login"],
full_name=user_data.get("full_name") or "",
email=user_data.get("email") or "",
)
repo_models = [
Repo(
id=r["id"], name=r["name"], full_name=r["full_name"],
description=r.get("description") or "", url=r["html_url"],
updated_at=r.get("updated_at", ""),
)
for r in (repo_data or [])[:50]
if isinstance(r, dict)
and all(field in r for field in ("id", "name", "full_name", "html_url"))
]
issue_models = [
Issue(
id=i["id"], number=i["number"], title=i["title"], state=i["state"],
labels=[label.get("name", "") for label in (i.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (i.get("assignees") or []) if isinstance(assignee, dict)],
repository=i["repository"].get("full_name", "") if isinstance(i.get("repository"), dict) else "",
updated_at=i.get("updated_at") or "", url=i["html_url"],
)
for i in (issues_data or [])[:50]
if isinstance(i, dict)
and all(field in i for field in ("id", "number", "title", "state", "html_url"))
]
pr_models = [
PullRequest(
id=p["id"], number=p["number"], title=p["title"], state=p["state"],
user=p["user"].get("login", "") if isinstance(p.get("user"), dict) else "",
labels=[label.get("name", "") for label in (p.get("labels") or []) if isinstance(label, dict)],
assignees=[assignee.get("login", "") for assignee in (p.get("assignees") or []) if isinstance(assignee, dict)],
work_reasons=[reason for reason in (p.get("work_reasons") or []) if reason in ("assigned_to_me", "review_requested")],
repository=p["repository"].get("full_name", "") if isinstance(p.get("repository"), dict) else "",
updated_at=p.get("updated_at") or "", url=p["html_url"],
)
for p in (prs_data or [])[:50]
if isinstance(p, dict)
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
]
return compute(user_model, repo_models, issue_models, pr_models).model_dump()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -49,9 +111,12 @@ app.include_router(frontend_router)
@app.middleware("http")
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
if request.url.path in {"/api/v1/context", "/api/v1/events"} or (
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")
):
response.headers["Cache-Control"] = "no-store"
return response
@@ -144,87 +209,72 @@ async def context() -> JSONResponse:
}, headers={
"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))
} if isinstance(e, TimeoutError) else None)
user_model = User(id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name") or "", email=user_data.get("email") or "")
repo_models = [
Repo(id=r["id"], name=r["name"], full_name=r["full_name"], description=r.get("description") or "", url=r["html_url"], updated_at=r.get("updated_at", ""))
for r in (repo_data or [])[:50]
if isinstance(r, dict)
and all(field in r for field in ("id", "name", "full_name", "html_url"))
]
issue_models = [
Issue(
id=i["id"],
number=i["number"],
title=i["title"],
state=i["state"],
labels=[
label.get("name", "")
for label in (i.get("labels") or [])
if isinstance(label, dict)
],
assignees=[
assignee.get("login", "")
for assignee in (i.get("assignees") or [])
if isinstance(assignee, dict)
],
repository=(
i["repository"].get("full_name", "")
if isinstance(i.get("repository"), dict)
else ""
),
updated_at=i.get("updated_at") or "",
url=i["html_url"],
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
async def _load_context_for_user(user_data: dict) -> dict:
repo_data, issues_data, prs_data = await asyncio.gather(
repos(), issues(), pull_requests()
)
if not all(field in user_data for field in ("id", "login")):
raise ContextPayloadError("Gitea current-user response did not include id and login")
if any(
data is not None and not isinstance(data, list)
for data in (repo_data, issues_data, prs_data)
):
raise ContextPayloadError("Gitea collection response was not a list")
return _context_payload(user_data, repo_data, issues_data, prs_data)
async def _build_live_snapshot() -> dict:
user_data = await current_user()
if not isinstance(user_data, dict) or not user_data.get("login"):
raise ContextPayloadError("Gitea current-user response was invalid")
context_result, events_result, notifications_result = await asyncio.gather(
_load_context_for_user(user_data),
activity_events(user_data),
notifications(),
return_exceptions=True,
)
context_ok = not isinstance(context_result, BaseException)
events_ok = not isinstance(events_result, BaseException)
notifications_ok = not isinstance(notifications_result, BaseException)
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,
"sections": {
"context": "fresh" if context_ok else "temporarily unavailable",
"events": "fresh" if events_ok else "temporarily unavailable",
"notifications": "fresh" if notifications_ok else "temporarily unavailable",
},
}
@app.get("/api/v1/live")
async def live_snapshot() -> JSONResponse:
"""Return a fresh, section-aware snapshot; join only an active identical load."""
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
try:
result = await asyncio.wait_for(task, timeout=CONTEXT_TIMEOUT_SECONDS)
return JSONResponse(result)
except TimeoutError:
return JSONResponse(
{"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"},
status_code=503,
headers={"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))},
)
for i in (issues_data or [])[:50]
if isinstance(i, dict)
and all(
field in i
for field in ("id", "number", "title", "state", "html_url")
except Exception:
return JSONResponse(
{"error": "Gitea live snapshot is temporarily unavailable"},
status_code=503,
)
]
pr_models = [
PullRequest(
id=p["id"],
number=p["number"],
title=p["title"],
state=p["state"],
user=(
p["user"].get("login", "")
if isinstance(p.get("user"), dict)
else ""
),
labels=[
label.get("name", "")
for label in (p.get("labels") or [])
if isinstance(label, dict)
],
assignees=[
assignee.get("login", "")
for assignee in (p.get("assignees") or [])
if isinstance(assignee, dict)
],
work_reasons=[
reason
for reason in (p.get("work_reasons") or [])
if reason in ("assigned_to_me", "review_requested")
],
repository=(
p["repository"].get("full_name", "")
if isinstance(p.get("repository"), dict)
else ""
),
updated_at=p.get("updated_at") or "",
url=p["html_url"],
)
for p in (prs_data or [])[:50]
if isinstance(p, dict)
and all(
field in p
for field in ("id", "number", "title", "state", "html_url")
)
]
ctx = compute(user_model, repo_models, issue_models, pr_models)
return JSONResponse(ctx.model_dump())
finally:
if task.done() and _live_snapshot_task is task:
_live_snapshot_task = None
@app.get("/api/v1/events")
@@ -260,21 +310,51 @@ async def event_stream():
)
@app.patch("/api/v1/notifications/{thread_id}/read")
async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
await asyncio.wait_for(
mark_notification_read(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Marking the update read timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The update could not be marked read. Please retry."},
status_code=503,
)
return JSONResponse({"id": thread_id, "status": "read"})
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
async def review_detail(owner: str, repo: str, number: int):
try:
async def load_requested_review():
repository = f"{owner}/{repo}"
if not await asyncio.wait_for(
is_requested_review(repository, number),
timeout=CONTEXT_TIMEOUT_SECONDS,
):
if not await is_requested_review(repository, number):
raise HTTPException(status_code=404, detail="Review request not found")
return await pull_review_detail(repository, number)
try:
return await asyncio.wait_for(
pull_review_detail(repository, number),
timeout=CONTEXT_TIMEOUT_SECONDS,
load_requested_review(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Pull request review details timed out. Please retry."},
status_code=503,
headers={
"Retry-After": str(
max(1, math.ceil(REVIEW_DETAIL_TIMEOUT_SECONDS))
)
},
)
except Exception:
return JSONResponse(
{"error": "Pull request review details are temporarily unavailable"},
+2 -2
View File
@@ -12,7 +12,7 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
async def empty_collection():
return []
async def empty_events():
async def empty_events(user_data=None):
return []
monkeypatch.setattr(main, "current_user", user)
@@ -23,7 +23,7 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for path in ("/api/v1/context", "/api/v1/events"):
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
response = await client.get(path)
assert response.status_code == 200
+2 -2
View File
@@ -15,6 +15,6 @@ def test_api_requests_resolve_inside_dashboard_subpath():
urljoin("https://forge.alexanderwhitestone.com/dashboard/", path)
for path in api_paths
} == {
"https://forge.alexanderwhitestone.com/dashboard/api/v1/context",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/events",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/live",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/notifications/",
}
+18 -3
View File
@@ -17,8 +17,9 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
assert "Gitea event stream" in html
assert "id=\"gitea-events\"" in html
assert "function paintEventStream" in html
assert "setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000)" in html
assert "if (!document.hidden) loadEventStream();" in html
assert "fetch('api/v1/live'" in html
assert "paintEventStream(snapshot.events)" in html
assert "loadEventStream" not in html
assert "event.actor?.login" in html
assert "event.repo?.full_name" in html
@@ -27,7 +28,6 @@ def test_event_stream_reports_when_activity_was_refreshed():
html = DASHBOARD.read_text()
assert 'id="gitea-events-status"' in html
assert "setEventStreamStatus('Updating…')" in html
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
@@ -121,6 +121,21 @@ async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
assert paths == ["users/timmy/activities/feeds?limit=20"]
@pytest.mark.anyio
async def test_activity_events_reuses_supplied_authenticated_user(monkeypatch):
async def unexpected_current_user():
raise AssertionError("current user must not be fetched twice")
async def fake_fetch(path):
assert path == "users/timmy/activities/feeds?limit=20"
return []
monkeypatch.setattr(gitea_proxy, "current_user", unexpected_current_user)
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
assert await gitea_proxy.activity_events({"login": "timmy"}) == []
@pytest.mark.anyio
async def test_activity_events_skips_malformed_feed_entries(monkeypatch):
async def fake_current_user():
+88
View File
@@ -0,0 +1,88 @@
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 fake_fetch(path):
requested_paths.append(path)
return [
{
"id": 42,
"unread": True,
"updated_at": "2026-08-06T12:30:00Z",
"repository": {"full_name": "stackchain/api"},
"subject": {
"title": "Retry failed deploy",
"type": "Issue",
"state": "open",
"html_url": "https://forge.example/stackchain/api/issues/7",
"latest_comment_html_url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
},
{"id": 43, "repository": None, "subject": None},
{
"id": 44,
"repository": {"full_name": "stackchain/web"},
"subject": {"title": "Unsafe", "html_url": "javascript:alert(1)"},
},
"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,
"unread": True,
"updated_at": "2026-08-06T12:30:00Z",
"repository": "stackchain/api",
"number": 7,
"title": "Retry failed deploy",
"subject_type": "Issue",
"state": "open",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
"subject_url": "https://forge.example/stackchain/api/issues/7",
},
{
"id": 43,
"unread": False,
"updated_at": "",
"repository": "",
"number": None,
"title": "Untitled update",
"subject_type": "Update",
"state": "",
"url": "",
"subject_url": "",
},
{
"id": 44,
"unread": False,
"updated_at": "",
"repository": "stackchain/web",
"number": None,
"title": "Unsafe",
"subject_type": "Update",
"state": "",
"url": "",
"subject_url": "",
},
]
@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)
with pytest.raises(ValueError, match="notification response was not a list"):
await gitea_proxy.notifications()
+43
View File
@@ -0,0 +1,43 @@
import httpx
import pytest
from src import gitea_proxy
from src import main
@pytest.mark.anyio
async def test_gitea_transport_is_reused_across_requests_and_closed():
client_ids = []
async def handler(request):
client_ids.append(id(gitea_proxy._client))
return httpx.Response(200, json={"path": request.url.path})
client = gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
first = await gitea_proxy.fetch("user")
second = await gitea_proxy.fetch("user/repos")
finally:
await gitea_proxy.stop_client()
assert first == {"path": "/api/v1/user"}
assert second == {"path": "/api/v1/user/repos"}
assert client_ids == [id(client), id(client)]
assert client.is_closed
@pytest.mark.anyio
async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch):
calls = []
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
calls.append("stop")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
async with main.app.router.lifespan_context(main.app):
assert calls == ["start"]
assert calls == ["start", "stop"]
+78 -7
View File
@@ -1,6 +1,8 @@
import pytest
import asyncio
import json
import pytest
from src import gitea_proxy
from src import main
@@ -54,26 +56,55 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
@pytest.mark.anyio
async def test_requested_review_guard_matches_repository_number_and_reason(monkeypatch):
async def pulls():
async def test_pull_request_searches_start_concurrently(monkeypatch):
started = [asyncio.Event(), asyncio.Event()]
release = asyncio.Event()
async def fake_fetch(path):
index = 0 if "assigned=true" in path else 1
started[index].set()
await release.wait()
return []
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
task = asyncio.create_task(gitea_proxy.pull_requests())
try:
await asyncio.wait_for(
asyncio.gather(*(event.wait() for event in started)), timeout=1
)
finally:
release.set()
assert await task == []
@pytest.mark.anyio
async def test_requested_review_guard_uses_only_dedicated_review_search(monkeypatch):
requested_paths = []
async def fake_fetch(path):
requested_paths.append(path)
return [
{
"number": 7,
"repository": {"full_name": "stackchain/api"},
"work_reasons": ["review_requested"],
},
{
"number": 8,
"repository": {"full_name": "stackchain/api"},
"work_reasons": ["assigned_to_me"],
},
]
monkeypatch.setattr(gitea_proxy, "pull_requests", pulls)
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True
assert await gitea_proxy.is_requested_review("stackchain/api", 8) is False
assert await gitea_proxy.is_requested_review("stackchain/api", 9) is False
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
assert requested_paths == [
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
]
@pytest.mark.anyio
@@ -175,6 +206,7 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
"repos/stackchain/api/pulls/7/reviews",
]
assert detail["author"] == "alex"
assert detail["head_sha"] == "abc123"
assert detail["ci_state"] == "success"
assert detail["files"][0]["filename"] == "src/api.py"
assert detail["reviews"][0]["state"] == "APPROVED"
@@ -182,6 +214,45 @@ async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypat
assert len(detail["reviews"]) == 1
@pytest.mark.anyio
async def test_pull_review_detail_fetches_independent_head_resources_concurrently(monkeypatch):
started = {name: asyncio.Event() for name in ("files", "status", "reviews", "diff")}
release = asyncio.Event()
async def wait_for_release(name, result):
started[name].set()
await release.wait()
return result
async def fake_fetch(path):
if path.endswith("/pulls/7"):
return {"head": {"sha": "abc123"}, "user": {"login": "alex"}}
if path.endswith("/files"):
return await wait_for_release("files", [])
if path.endswith("/reviews"):
return await wait_for_release("reviews", [])
return await wait_for_release("status", {"state": "success"})
async def fake_fetch_text(path, max_bytes):
return await wait_for_release("diff", ("", False))
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
detail_task = asyncio.create_task(
gitea_proxy.pull_review_detail("stackchain/api", 7)
)
try:
await asyncio.wait_for(
asyncio.gather(*(event.wait() for event in started.values())), timeout=1
)
finally:
release.set()
detail = await detail_task
assert detail["head_sha"] == "abc123"
@pytest.mark.anyio
async def test_pull_review_detail_attaches_bounded_per_file_diff_previews(monkeypatch):
async def fake_fetch(path):
+186
View File
@@ -0,0 +1,186 @@
import asyncio
import json
import pytest
from src import main
@pytest.fixture(autouse=True)
def reset_live_snapshot_task():
main._live_snapshot_task = None
yield
main._live_snapshot_task = None
def payload(response):
return json.loads(response.body)
@pytest.mark.anyio
async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(monkeypatch):
calls = {"user": 0}
async def user():
calls["user"] += 1
return {"id": 1, "login": "timmy"}
async def empty():
return []
async def events(authenticated_user):
assert authenticated_user["login"] == "timmy"
return [{"type": "push"}]
async def updates():
return [{"id": 42, "title": "Mentioned you"}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", updates)
response = await main.live_snapshot()
result = payload(response)
assert calls["user"] == 1
assert result["context"]["user"]["login"] == "timmy"
assert result["events"] == [{"type": "push"}]
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
assert result["sections"] == {
"context": "fresh", "events": "fresh", "notifications": "fresh"
}
@pytest.mark.anyio
async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
async def failing_events(authenticated_user):
raise ConnectionError("secret upstream detail")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", failing_events)
monkeypatch.setattr(main, "notifications", empty)
result = payload(await main.live_snapshot())
assert result["context"]["user"]["login"] == "timmy"
assert result["events"] is None
assert result["sections"] == {
"context": "fresh",
"events": "temporarily unavailable",
"notifications": "fresh",
}
assert "secret" not in json.dumps(result)
@pytest.mark.anyio
async def test_live_snapshot_keeps_work_and_activity_when_notifications_fail(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
async def events(_authenticated_user):
return [{"type": "push"}]
async def failing_updates():
raise ConnectionError("private notification failure")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", failing_updates)
result = payload(await main.live_snapshot())
assert result["context"]["user"]["login"] == "timmy"
assert result["events"] == [{"type": "push"}]
assert result["notifications"] is None
assert result["sections"]["notifications"] == "temporarily unavailable"
assert "private" not in json.dumps(result)
@pytest.mark.anyio
async def test_live_snapshot_keeps_fresh_activity_when_work_fails(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def failing_repos():
raise ConnectionError("work unavailable")
async def empty():
return []
async def events(authenticated_user):
return [{"type": "push"}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", failing_repos)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", empty)
result = payload(await main.live_snapshot())
assert result["context"] is None
assert result["events"] == [{"type": "push"}]
assert result["sections"] == {
"context": "temporarily unavailable",
"events": "fresh",
"notifications": "fresh",
}
@pytest.mark.anyio
async def test_live_snapshot_coalesces_only_simultaneous_requests(monkeypatch):
user_calls = 0
release = asyncio.Event()
async def user():
nonlocal user_calls
user_calls += 1
return {"id": 1, "login": "timmy"}
async def blocked_repos():
await release.wait()
return []
async def empty():
return []
async def events(authenticated_user):
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", blocked_repos)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", empty)
first = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
second = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
assert user_calls == 1
release.set()
await asyncio.gather(first, second)
await main.live_snapshot()
assert user_calls == 2
+341 -1
View File
@@ -113,7 +113,147 @@ process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1}
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1, "update": 0}
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
payload = {
"user": {"login": "timmy"},
"issues": [{
"id": 1, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7",
}],
"pull_requests": [],
"notifications": [
{
"id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
"subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T12:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
{
"id": 43, "number": 8, "title": "Mention only", "repository": "stackchain/web",
"subject_type": "Issue", "unread": True, "updated_at": "2026-08-06T13:00:00Z",
"url": "https://forge.example/stackchain/web/issues/8#issuecomment-2",
},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const queue = buildMyWork({json.dumps(payload)});
process.stdout.write(JSON.stringify({{
queue,
updates: buildMyWork.filterMyWork(queue, 'update'),
counts: buildMyWork.countMyWork(queue),
summary: buildMyWork.summarizeMyWork(queue),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert len(output["queue"]) == 2
assert [item["key"] for item in output["updates"]] == ["stackchain/web#8", "stackchain/api#7"]
assert output["updates"][0]["kind"] == "update"
assert output["updates"][1]["kind"] == "issue"
assert output["updates"][1]["url"].endswith("#issuecomment-9")
assert output["counts"] == {"all": 2, "issue": 1, "pull": 0, "review": 0, "update": 2}
assert output["summary"] == "2 unread updates · 0 reviews · 1 assigned"
def test_unread_update_correlation_distinguishes_issue_and_pull_with_same_number():
payload = {
"user": {"login": "timmy"},
"issues": [{"number": 7, "title": "Issue seven", "repository": "stackchain/api", "url": "https://forge.example/issues/7"}],
"pull_requests": [{"number": 7, "title": "Pull seven", "repository": "stackchain/api", "url": "https://forge.example/pulls/7"}],
"notifications": [{
"id": 42, "number": 7, "title": "Issue seven", "repository": "stackchain/api",
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment-1",
}],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
process.stdout.write(JSON.stringify(buildMyWork({json.dumps(payload)})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
queue = json.loads(result.stdout)
issue = next(item for item in queue if item["kind"] == "issue")
pull = next(item for item in queue if item["kind"] == "pull")
assert issue["has_update"] is True
assert issue["url"].endswith("#comment-1")
assert pull["has_update"] is False
def test_notification_identity_survives_merge_and_acknowledgement_preserves_assigned_work():
payload = {
"user": {"login": "timmy"},
"issues": [{
"number": 7, "title": "Assigned issue", "repository": "stackchain/api",
"assignees": ["timmy"], "url": "https://forge.example/issues/7",
}],
"notifications": [
{"id": 42, "number": 7, "title": "Assigned issue", "repository": "stackchain/api",
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/7#comment"},
{"id": 43, "number": 8, "title": "Mention", "repository": "stackchain/web",
"subject_type": "Issue", "unread": True, "url": "https://forge.example/issues/8"},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const before = buildMyWork({json.dumps(payload)});
const afterMerged = buildMyWork.acknowledgeNotification(before, 42);
const afterStandalone = buildMyWork.acknowledgeNotification(before, 43);
process.stdout.write(JSON.stringify({{before, afterMerged, afterStandalone}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assigned = next(item for item in output["before"] if item["kind"] == "issue")
assert assigned["notification_id"] == 42
assert assigned["has_update"] is True
assert len(output["afterMerged"]) == 2
acknowledged = next(item for item in output["afterMerged"] if item["kind"] == "issue")
assert acknowledged["has_update"] is False
assert "notification_id" not in acknowledged
assert [item["notification_id"] for item in output["afterStandalone"] if item["has_update"]] == [42]
def test_notification_acknowledger_is_single_flight_and_rolls_back_on_failure():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const original = [{{kind:'update', notification_id:42, has_update:true}}];
let calls = 0;
let release;
const states = [];
const statuses = [];
const controller = buildMyWork.createNotificationAcknowledger({{
markRead: () => {{ calls += 1; return new Promise((resolve, reject) => {{ release = reject; }}); }},
onItems: items => states.push(items),
onStatus: status => statuses.push(status),
}});
const first = controller.acknowledge(original, 42);
const duplicate = controller.acknowledge(original, 42);
release(new Error('offline'));
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, states, 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["states"] == [[], [{"kind": "update", "notification_id": 42, "has_update": True}]]
assert output["statuses"] == ["Marking update read…", "Could not mark update read. Retry."]
assert output["results"] == [False, False]
@pytest.mark.anyio
@@ -125,6 +265,7 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
assert 'data-work-filter="issue"' in html
assert 'data-work-filter="pull"' in html
assert 'data-work-filter="review"' in html
assert 'data-work-filter="update"' in html
assert '.work-filter' in html and 'min-height: 44px' in html
assert '.my-work-card' in html and 'min-height: 44px' in html
assert '<script src="static/my-work.js"></script>' in html
@@ -133,6 +274,20 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
@pytest.mark.anyio
async def test_unread_cards_offer_accessible_mobile_mark_read_without_nested_actions():
html = await dashboard()
assert '.mark-update-read' in html and 'min-height:44px' in html
assert 'data-notification-id' in html
assert 'Unread update' in html
assert 'id="my-work-action-status"' in html and 'aria-live="assertive"' in html
assert "createNotificationAcknowledger" in html
assert "method: 'PATCH'" in html
assert "api/v1/notifications/" in html
assert '<a class="my-work-card"' not in html
@pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
html = await dashboard()
@@ -142,6 +297,8 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="issue"' in html
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert "['all', 'issue', 'pull', 'review', 'update'].includes(savedFilter)" in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
@@ -197,10 +354,152 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
assert '-old &lt;token&gt;' in output["html"]
assert '+new &amp; safe' in output["html"]
assert 'Preview truncated' in output["html"]
assert 'class="review-mark"' in output["html"]
assert 'data-review-filename="src/&lt;api&gt;.py"' in output["html"]
assert 'Mark reviewed' in output["html"]
assert output["expanded"] == "true"
assert output["hidden"] is False
def test_review_progress_is_explicit_and_restores_for_the_same_head_sha():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
setItem(key, value) {{ values.set(key, value); }}
}};
const options = {{
storage, repository: 'stackchain/api', number: 7, headSha: 'abc123',
files: [{{filename:'src/a.py'}}, {{filename:'src/b.py'}}, {{filename:'README.md'}}]
}};
const first = reviewSheet.createProgress(options);
const before = first.snapshot();
const marked = first.markReviewed('src/a.py');
const restored = reviewSheet.createProgress(options).snapshot();
process.stdout.write(JSON.stringify({{ before, marked, restored }}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["before"] == {
"reviewed": [], "reviewedCount": 0, "total": 3, "nextFilename": "src/a.py"
}
assert output["marked"] == {
"reviewed": ["src/a.py"], "reviewedCount": 1, "total": 3,
"nextFilename": "src/b.py",
}
assert output["restored"] == output["marked"]
def test_review_progress_resets_when_new_commits_change_the_head_sha():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
setItem(key, value) {{ values.set(key, value); }}
}};
const base = {{ storage, repository: 'stackchain/api', number: 7, files: [{{filename:'src/a.py'}}] }};
reviewSheet.createProgress({{...base, headSha:'abc123'}}).markReviewed('src/a.py');
const changed = reviewSheet.createProgress({{...base, headSha:'def456'}}).snapshot();
process.stdout.write(JSON.stringify(changed));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"reviewed": [], "reviewedCount": 0, "total": 1,
"nextFilename": "src/a.py", "newHead": True,
}
def test_review_feedback_draft_restores_notes_summary_and_decision_for_same_head():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
getItem(key) {{ return values.has(key) ? values.get(key) : null; }},
setItem(key, value) {{ values.set(key, value); }}
}};
const options = {{
storage, repository: 'stackchain/api', number: 7, headSha: 'abc123',
files: [{{filename:'src/a.py'}}, {{filename:'src/b.py'}}]
}};
const first = reviewSheet.createDraft(options);
first.setNote('src/a.py', 'Handle the empty state.');
first.setSummary('One blocker remains.');
first.setDecision('request_changes');
const restored = reviewSheet.createDraft(options).snapshot();
process.stdout.write(JSON.stringify(restored));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"notes": {"src/a.py": "Handle the empty state."},
"summary": "One blocker remains.",
"decision": "request_changes",
}
def test_review_feedback_formats_non_empty_file_notes_in_changed_file_order():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const markdown = reviewSheet.formatFeedback({{
decision: 'request_changes', summary: 'One blocker remains.',
notes: {{'src/b.py':'Second note', 'src/a.py':'First note', 'README.md':''}}
}}, [{{filename:'src/a.py'}}, {{filename:'README.md'}}, {{filename:'src/b.py'}}]);
process.stdout.write(markdown);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert result.stdout == (
"## Intended decision\nRequest changes\n\n"
"## Summary\nOne blocker remains.\n\n"
"## File notes\n### `src/a.py`\nFirst note\n\n"
"### `src/b.py`\nSecond note"
)
def test_review_handoff_opens_gitea_only_after_copy_and_preserves_failed_copy():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const events = [];
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),
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),
}});
Promise.all([success, failure]).then(results => process.stdout.write(JSON.stringify({{
events, failedEvents, results
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"events": ["copy:feedback", "open:https://forge.example/pulls/7"],
"failedEvents": ["fallback:keep me"],
"results": [True, False],
}
@pytest.mark.anyio
async def test_review_requests_open_an_accessible_mobile_detail_sheet():
html = await dashboard()
@@ -214,6 +513,13 @@ async def test_review_requests_open_an_accessible_mobile_detail_sheet():
assert '.review-sheet-panel' in html and 'width:100%' in html
assert '.review-action' in html and 'min-height:44px' in html
assert '.review-file-toggle' in html and 'min-height:44px' in html
assert '.review-mark' in html and 'min-height:44px' in html
assert 'id="review-progress"' in html and 'aria-live="polite"' in html
assert 'id="next-unreviewed-review"' in html
assert '.review-progress-actions' in html and 'position:sticky' in html
assert "createReviewController.createProgress" in html
assert "progress.markReviewed" in html
assert "scrollIntoView" in html
assert '.review-diff' in html and 'overflow-x:auto' in html
@@ -227,3 +533,37 @@ async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
assert "review-history" in html
assert "open-review-gitea" in html
assert "reviewController.submit" not in html
@pytest.mark.anyio
async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
html = await dashboard()
review_script = REVIEW_SHEET.read_text()
assert 'class="review-note"' in review_script
assert 'id="review-decision"' in html
assert 'id="review-summary"' in html
assert 'id="copy-review-feedback"' in html
assert 'id="review-copy-fallback"' 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
assert "draft.setNote" in html
assert "draft?.setSummary" in html
assert "draft?.setDecision" in html
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
@pytest.mark.anyio
async def test_mobile_review_failure_offers_an_in_place_retry_for_the_same_item():
html = await dashboard()
assert 'id="retry-review-load"' in html
assert '.review-retry' in html and 'min-height:44px' in html
assert "qs('#retry-review-load').hidden = false" in html
assert "qs('#retry-review-load').hidden = true" in html
assert "openReviewSheet(selectedReview, reviewTrigger)" in html
assert "qs('#retry-review-load').focus()" in html
+47
View File
@@ -0,0 +1,47 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monkeypatch):
calls = []
class Response:
def raise_for_status(self):
return None
class Client:
async def patch(self, path, headers):
calls.append((path, headers))
return Response()
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
await gitea_proxy.mark_notification_read(42)
assert calls == [
("/api/v1/notifications/threads/42?to-status=read", {"Authorization": "token test"})
]
@pytest.mark.anyio
async def test_mark_notification_read_api_is_bounded_and_never_cacheable(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:
response = await client.patch("/api/v1/notifications/42/read")
invalid = await client.patch("/api/v1/notifications/0/read")
assert response.status_code == 200
assert response.json() == {"id": 42, "status": "read"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert marked == [42]
+27
View File
@@ -1,3 +1,5 @@
import asyncio
import httpx
import pytest
@@ -48,6 +50,31 @@ async def test_review_detail_rejects_pulls_not_requested_from_service_user(monke
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_review_detail_has_one_retryable_deadline_and_cancels_pending_work(monkeypatch):
cancelled = asyncio.Event()
async def requested(repository, number):
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/pulls/7/review")
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert response.headers["retry-after"] == "1"
assert response.json() == {
"error": "Pull request review details timed out. Please retry."
}
assert cancelled.is_set()
@pytest.mark.anyio
async def test_public_review_endpoint_does_not_expose_service_token_mutations():
transport = httpx.ASGITransport(app=main.app)
+13 -1
View File
@@ -14,4 +14,16 @@ async def test_one_context_snapshot_updates_every_context_backed_panel():
assert "setInterval(tickWidgets, 2000)" not in html
assert "updateRepoMix(qs('#repo-mix'), fetch)" not in html
assert "setInterval(load, 8000)" not in html
assert "document.addEventListener('visibilitychange'" in html
assert "document.addEventListener('visibilitychange'" in html
@pytest.mark.anyio
async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
html = await dashboard()
assert "fetch('api/v1/live'" in html
assert "renderLiveSnapshot(snapshot)" in html
assert "renderContextSnapshot(snapshot.context)" in html
assert "paintEventStream(snapshot.events)" in html
assert "loadEventStream" not in html
assert "5000" not in html