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
timmy 6c4ce5cf48 feat: preview mobile review diffs (#123)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 17:31:40 +00:00
rockachopa f662361b09 Merge pull request 'Add a mobile review readiness inspector' (#122) from timmy/121-mobile-review-completion into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 17:10:37 +00:00
timmy 83788c6b82 feat: add mobile review readiness inspector (#121)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 6s
2026-08-06 17:09:40 +00:00
rockachopa 329591ebd6 Make mobile My Work refresh resumable and single-flight (#120)
CI / lint (push) Successful in 12s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 6s
Closes #119
2026-08-06 16:35:13 +00:00
timmy 32d9d9413f feat: make My Work refresh resumable (#119)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 6s
2026-08-06 16:30:01 +00:00
rockachopa b5830cf86f Merge pull request 'Add review requests to the mobile My Work inbox' (#118) from timmy/117-mobile-review-inbox into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 15:53:13 +00:00
timmy b7d4bd8137 feat: add review requests to My Work (#117)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 15:52:26 +00:00
rockachopa 26ffa1f1b3 Merge pull request 'Add a mobile My Work inbox backed by supported Gitea search' (#116) from timmy/115-mobile-my-work-inbox into main
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Merge pull request 'Add a mobile My Work inbox backed by supported Gitea search' (#116) from timmy/115-mobile-my-work-inbox into main
2026-08-06 15:25:27 +00:00
timmy de2f8d8fcc feat: add mobile My Work inbox (#115)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 15:24:37 +00:00
rockachopa dce7373845 Merge pull request 'Hide upstream exception details from readiness responses' (#114) from timmy/113-hide-readiness-exception-details into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 14:48:42 +00:00
timmy 6be016d658 fix: hide readiness exception details (#113)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 14:47:52 +00:00
rockachopa 2891a0cfd8 Merge pull request 'Hide upstream exception details from fallback context responses' (#112) from timmy/111-hide-context-exception-details into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 14:18:13 +00:00
timmy f2540fb1db fix: hide context exception details (#111)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 14:17:30 +00:00
rockachopa 7f236c5a79 Merge pull request 'Prevent shared caches from storing live Gitea API responses' (#110) from timmy/109-prevent-shared-api-caching into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 13:48:14 +00:00
timmy 7c13bb1663 fix: prevent caching live API responses (#109)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 13:47:27 +00:00
rockachopa 37dd7710b4 Merge pull request 'Return retryable event response for malformed Gitea activity feed payload' (#108) from timmy/107-return-retryable-malformed-activity-feed into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 13:19:03 +00:00
timmy c21d968cc2 fix: reject malformed activity feed payloads (#107)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 13:17:35 +00:00
timmy d86e86e1fb Merge pull request 'Remove fabricated fallback work items' (#106) from timmy/105-do-not-display-fabricated-work-items into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Remove fabricated fallback work items (#106)
2026-08-06 12:47:54 +00:00
timmy bfdc042e38 fix: remove fabricated fallback work items (#105)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 12:47:12 +00:00
rockachopa acc60d11e7 Report repo mix context failures (#104)
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #103
2026-08-06 12:17:51 +00:00
timmy b055644be9 fix: report repo mix context failures (#103)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 12:17:13 +00:00
rockachopa 4602347a80 Merge pull request 'Allow mobile users to zoom the dashboard' (#102) from timmy/101-allow-mobile-dashboard-zoom into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 11:47:33 +00:00
timmy 64b6ff6c5b fix: allow mobile dashboard zoom (#101)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 11:46:46 +00:00
rockachopa 0427b3079e Merge pull request 'Return fallback for malformed Gitea collection payloads' (#100) from timmy/99-return-fallback-malformed-collection-payloads into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 11:17:42 +00:00
timmy f4e2969fec fix: handle malformed Gitea collections (#99)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 11:16:40 +00:00
rockachopa 7d5d1181c0 Merge pull request 'Show degraded status for fallback dashboard context' (#98)
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 10:47:58 +00:00
timmy b41d7ad03a fix: expose degraded fallback context status (#97)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 10:47:18 +00:00
rockachopa b2de3b7eeb Merge pull request 'Normalize malformed nested Gitea activity metadata' (#96) from timmy/95-normalize-malformed-nested-activity-metadata into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 10:17:39 +00:00
timmy 1b43116fe6 fix: normalize nested activity metadata (#95)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 10:16:51 +00:00
timmy 8f3bbde733 Merge pull request 'Normalize null Gitea activity feed payloads' (#94) from timmy/93-normalize-null-activity-feed into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 09:47:06 +00:00
timmy 6e24d7db17 fix: normalize null activity feed payloads (#93)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:46:24 +00:00
rockachopa 43a2804562 Merge pull request 'Return fallback context for incomplete current-user payload' (#92) from timmy/91-return-fallback-incomplete-current-user into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 09:18:02 +00:00
timmy 42b659e807 fix: fallback on incomplete current user (#91)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:17:16 +00:00
rockachopa 240d962ec3 Merge pull request 'Skip malformed Gitea activity feed entries' (#90) from timmy/89-skip-malformed-activity-feed-entries into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 5s
2026-08-06 09:01:36 +00:00
timmy 2aba489036 fix: skip malformed activity feed entries (#89)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:00:45 +00:00
rockachopa 10181aa019 Merge pull request 'Normalize malformed Gitea pull request authors' (#88) from timmy/87-normalize-malformed-gitea-pr-authors into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 08:18:28 +00:00
timmy 2d492d9d2e fix: normalize malformed pull request authors (#87)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 08:17:42 +00:00
timmy 1fe16c1342 Merge pull request 'Skip malformed nested Gitea issue metadata entries' (#86) from timmy/85-skip-malformed-nested-issue-metadata into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 07:48:49 +00:00
timmy 68c2048724 fix: skip malformed nested issue metadata (#85)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 07:48:04 +00:00
timmy fcdca7a37c Skip malformed Gitea pull request entries (#84)
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #83
2026-08-06 07:18:25 +00:00
timmy 77404cfd03 fix: skip malformed pull request entries (#83)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 07:17:40 +00:00
timmy 49ce2d9945 Merge pull request 'Skip malformed Gitea issue entries' (#82) from timmy/81-skip-malformed-gitea-issue-entries into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
Closes #81
2026-08-06 06:48:38 +00:00
timmy 5f86b58b88 fix: skip malformed issue entries (#81)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 06:47:47 +00:00
rockachopa 888c03e4c1 Merge pull request 'Skip malformed Gitea repository entries' (#80) from timmy/79-skip-malformed-repository-entries into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 06:19:24 +00:00
timmy bce46843b3 fix: skip malformed repository entries (#79)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 06:18:22 +00:00
rockachopa 89b14872e6 Merge pull request 'Return not-ready for invalid Gitea current-user payload' (#78) from timmy/77-return-not-ready-invalid-current-user into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 05:48:27 +00:00
timmy 090a059c17 fix: reject invalid readiness user payload (#77)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 05:47:38 +00:00
timmy b2f8eaf89a Merge pull request 'Handle null Gitea current-user payload' (#76) from timmy/75-handle-null-gitea-current-user-payload into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 05:24:39 +00:00
timmy 8001933692 fix: handle null Gitea current-user payload (#75)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 05:23:58 +00:00
timmy d9ad68b72b Merge pull request 'Normalize nullable Gitea user profile fields' (#74) from timmy/73-normalize-nullable-user-profile-fields into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 05:03:45 +00:00
25 changed files with 3289 additions and 132 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'
+73
View File
@@ -0,0 +1,73 @@
function createContextPoller({
fetchContext,
onSnapshot,
onError,
isHidden = () => false,
setTimer = setTimeout,
clearTimer = clearTimeout,
intervalMs = 8000,
}) {
let inFlight = null;
let timer = null;
let stopped = false;
function cancelTimer() {
if (timer !== null) clearTimer(timer);
timer = null;
}
function schedule() {
cancelTimer();
if (stopped || isHidden()) return;
timer = setTimer(() => {
timer = null;
refresh();
}, intervalMs);
}
function refresh() {
if (stopped || isHidden()) return Promise.resolve(null);
if (inFlight) return inFlight;
let request;
try {
request = fetchContext();
} catch (error) {
request = Promise.reject(error);
}
inFlight = Promise.resolve(request)
.then((snapshot) => {
onSnapshot(snapshot);
return snapshot;
})
.catch((error) => {
onError(error);
return null;
})
.finally(() => {
inFlight = null;
schedule();
});
return inFlight;
}
function setVisible(visible) {
cancelTimer();
if (!visible) return Promise.resolve(null);
return refresh();
}
return {
start: refresh,
refresh,
setVisible,
stop() {
stopped = true;
cancelTimer();
},
};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createContextPoller;
}
+458 -82
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stackchain Dashboard</title>
<style>
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
@@ -54,6 +54,56 @@ textarea { resize: vertical; min-height: 120px; }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
.event:last-child { border-bottom: 0; }
.my-work { grid-column: 1 / -1; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
.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: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; }
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
.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; }
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
.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; }
.my-work-list { grid-template-columns:1fr; }
.work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-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; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@@ -72,6 +122,23 @@ textarea { resize: vertical; min-height: 120px; }
</header>
<main>
<section class="panel my-work" id="my-work">
<div class="my-work-header">
<div>
<h2>My Work</h2>
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</div>
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<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>
<summary><h2>Context &amp; view</h2></summary>
@@ -157,10 +224,56 @@ textarea { resize: vertical; min-height: 120px; }
</div>
</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">
<div>
<div class="small" id="review-sheet-key"></div>
<h3 id="review-sheet-title">Pull request review</h3>
</div>
<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>
</div>
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/widgets.js"></script>
<script src="static/my-work.js"></script>
<script src="static/review-sheet.js"></script>
<script src="static/context-poller.js"></script>
<script>
(function(){
const qs = (s, el=document) => el.querySelector(s);
@@ -187,59 +300,288 @@ textarea { resize: vertical; min-height: 120px; }
});
});
let liveMode = true;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
let selectedWorkFilter = 'all';
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
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);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Review request failed.');
return payload;
}
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
setClock(); setInterval(setClock, 1000);
async function load() {
setStatus('Loading…');
try {
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
liveMode = true;
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
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();
}
const issuesBox = qs('#issues-content');
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
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 prsBox = qs('#prs-content');
const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
const notificationAcknowledger = createNotificationAcknowledger({
markRead: markNotificationRead,
onItems: items => {
lastMyWork = items;
refreshMyWorkView();
},
onStatus: message => { qs('#my-work-action-status').textContent = message; },
});
paintDeltas(data.deltas || []);
setStatus('Live · updated just now');
setClock();
} catch (e) {
console.error('context failed', e);
liveMode = false;
setStatus('Mock');
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">Timmy Jr</div><div class="label">Repos</div><div class="value">3</div><div class="label">Issues</div><div class="value">—</div><div class="label">PRs</div><div class="value">—</div></div>';
qs('#view-hint').textContent = 'Active view: dashboard';
const mockIssues = [
{ number: 1, title: 'Kickoff issue', state: 'open', labels: ['epic'], url: '#1' },
{ number: 8, title: 'Live UI implementation', state: 'open', labels: ['frontend'], url: '#8' },
{ number: 37, title: 'Docker build and push', state: 'open', labels: ['devops'], url: '#37' },
];
qs('#issues-content').innerHTML = mockIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '">#' + i.number + ' ' + escapeHtml(i.title) + '</a><div class="muted">' + (i.labels||[]).map(l=>'<span class="pill">'+escapeHtml(String(l))+'</span>').join(' ') + '</div></div>').join('');
const mockPrs = [
{ number: 4, title: 'Agent UI delta binding', state: 'open', user: 'turdbot', url: '#4' },
];
qs('#prs-content').innerHTML = mockPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '">#' + p.number + ' ' + escapeHtml(p.title) + '</a><div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user||'')) + '</div></div>').join('');
paintDeltas([
{panel:'triage', action:'open', target:'#1', priority:'high'},
{panel:'review', action:'review', target:'#4', priority:'medium'},
{panel:'layout', action:'highlight', target:'issues', priority:'low'},
]);
function renderContextSnapshot(data) {
liveMode = true;
hasContextSnapshot = true;
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>' +
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
const issuesBox = qs('#issues-content');
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
const prsBox = qs('#prs-content');
const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
paintDeltas(data.deltas || []);
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
renderRepoMix(qs('#repo-mix'), data);
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
setClock();
}
function handleContextError(e) {
console.error('context failed', e);
liveMode = false;
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
if (!hasContextSnapshot) {
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
qs('#view-hint').textContent = 'Active view unavailable.';
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
paintDeltas([]);
}
markMyWorkStale();
}
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 + '"]');
if (element) element.textContent = count;
});
qs('#my-work').removeAttribute('data-stale');
qs('#my-work-status').textContent = lastMyWork.length ?
summarizeMyWork(lastMyWork) : 'No assigned work, review requests, or unread updates.';
renderMyWork();
}
function renderMyWork() {
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' : (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 '<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"><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) {
selectedReview = item;
reviewTrigger = trigger;
qs('#review-sheet').classList.add('open');
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);
if (selectedReview !== item) return;
qs('#review-sheet-body').textContent = detail.body || 'No description provided.';
qs('#review-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
qs('#review-files').innerHTML = (detail.files || []).length ? detail.files.map((file, index) =>
createReviewController.renderDiffFile(file, index, escapeHtml)
).join('') : '<div>No changed files reported.</div>';
document.querySelectorAll('.review-file-toggle').forEach(button => {
button.addEventListener('click', () => {
const panel = document.getElementById(button.getAttribute('aria-controls'));
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) {
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();
}
function markMyWorkStale() {
qs('#my-work').setAttribute('data-stale', 'true');
qs('#my-work-status').textContent = lastMyWork.length ?
'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) {
@@ -261,27 +603,22 @@ 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');
}
}
async function tickWidgets() {
try {
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
const data = await res.json();
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
} catch (e) {
qs('#layout-hint').innerHTML = '<div class="muted">Mock layout mode</div>';
}
}
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return escapeHtml(s); }
@@ -351,31 +688,70 @@ 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('#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: fetchLiveSnapshot,
onSnapshot: renderLiveSnapshot,
onError: error => {
handleContextError(error);
setEventStreamStatus('Update failed · showing last activity');
},
isHidden: () => document.hidden,
intervalMs: 8000,
});
function load() { return contextPoller.refresh(); }
qs('#refresh').addEventListener('click', load);
load();
loadEventStream();
setInterval(load, 8000);
setInterval(loadEventStream, 5000);
setInterval(tickWidgets, 2000);
tickWidgets();
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => {
selectedWorkFilter = button.dataset.workFilter;
try {
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
} catch (e) {
console.warn('Could not persist My Work filter', e);
}
document.querySelectorAll('[data-work-filter]').forEach(item =>
item.setAttribute('aria-pressed', String(item === button))
);
renderMyWork();
});
});
contextPoller.start();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
});
/* Widgets */
async function updateRepoMix() {
const el = qs('#repo-mix');
try {
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
const data = await res.json();
const repos = data.repos || [];
const issues = (data.issues||[]).filter(i=>i.state==='open');
const prs = data.pull_requests || [];
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
} catch (e) {
el.innerHTML = '<div class="muted">Widget unavailable.</div>';
}
}
updateRepoMix();
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
})();
+128
View File
@@ -0,0 +1,128 @@
function buildMyWork(data) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
const priorityLabels = ['p0', 'priority-high', 'critical'];
const work = issues.concat(pulls).map((item) => {
const labels = item.labels || [];
const priorityLabel = labels.find((label) =>
priorityLabels.includes(String(label).toLowerCase())
);
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
return {
...item,
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 ? 2 : (assigned ? 3 : 4)),
};
});
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 (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
}
function countMyWork(items) {
return {
all: items.length,
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,
};
}
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
module.exports = buildMyWork;
}
+188
View File
@@ -0,0 +1,188 @@
function createReviewController({ fetchJson }) {
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
throw new Error('This review link is invalid.');
}
return 'api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
'/pulls/' + Number(item.number) + '/review';
}
async function load(item) {
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
return { load };
}
function diffLineClass(line) {
if (line.startsWith('@@')) return 'hunk';
if (line.startsWith('+')) return 'added';
if (line.startsWith('-')) return 'removed';
return 'context';
}
function renderDiffFile(file, index, escapeHtml) {
const panelId = 'review-diff-' + index;
let preview;
if (file.diff_available) {
const lines = (file.diff_lines || []).map(line =>
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
escapeHtml(String(line)) + '</span>'
).join('');
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
'</pre>';
} else {
const message = file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.';
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
}
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 +
'<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) {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
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;
}
+23
View File
@@ -0,0 +1,23 @@
function renderRepoMix(element, data) {
const repos = data.repos || [];
const issues = (data.issues || []).filter((issue) => issue.state === 'open');
const pullRequests = data.pull_requests || [];
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
}
async function updateRepoMix(element, fetchContext = fetch) {
try {
const response = await fetchContext('api/v1/context', {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('HTTP ' + response.status);
renderRepoMix(element, await response.json());
} catch (error) {
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
}
}
if (typeof module !== 'undefined' && module.exports) {
updateRepoMix.renderRepoMix = renderRepoMix;
module.exports = updateRepoMix;
}
+289 -13
View File
@@ -1,10 +1,16 @@
import asyncio
import os
import shlex
from typing import Any
from urllib.parse import urlsplit
import httpx
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]:
@@ -14,11 +20,88 @@ 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 _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
def _diff_previews(diff: str, stream_truncated: bool) -> dict[str, dict]:
previews: dict[str, dict] = {}
current: dict | None = None
in_hunk = False
remaining = REVIEW_DIFF_MAX_LINES
for line in diff.splitlines():
if line.startswith("diff --git "):
try:
target = shlex.split(line)[3]
filename = target[2:] if target.startswith("b/") else target
except (IndexError, ValueError):
current = None
continue
current = {
"diff_lines": [],
"diff_available": False,
"diff_binary": False,
"diff_truncated": stream_truncated,
}
previews[filename] = current
in_hunk = False
continue
if current is None:
continue
if line.startswith("Binary files ") or line == "GIT binary patch":
current["diff_binary"] = True
in_hunk = False
continue
if line.startswith("@@"):
in_hunk = True
if in_hunk and not line.startswith("\\ No newline at end of file"):
if remaining:
current["diff_lines"].append(line)
current["diff_available"] = True
remaining -= 1
else:
current["diff_truncated"] = True
return previews
async def current_user() -> dict:
@@ -30,22 +113,215 @@ async def repos() -> list[dict]:
async def issues() -> list[dict]:
return await fetch("user/issues?limit=50&type=all")
return await fetch(
"repos/issues/search?state=open&assigned=true&type=issues&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(),
)
response.raise_for_status()
async def pull_requests() -> list[dict]:
return await fetch("user/pulls?limit=50")
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 (
("assigned_to_me", assigned or []),
("review_requested", review_requested or []),
):
for pull in pulls:
identity = pull.get("id")
if identity not in merged:
merged[identity] = {**pull, "work_reasons": []}
merged[identity]["work_reasons"].append(reason)
return list(merged.values())
async def activity_events() -> list[dict]:
user = await current_user()
async def is_requested_review(repository: str, number: int) -> bool:
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 isinstance(pull.get("repository"), dict)
and pull["repository"].get("full_name") == repository
for pull in (pulls or [])
)
async def pull_review_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
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 ""
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 {}
normalized_files = [
{
"filename": file["filename"],
"status": file.get("status") or "changed",
"additions": file.get("additions") or 0,
"deletions": file.get("deletions") or 0,
**previews.get(
file["filename"],
{
"diff_lines": [],
"diff_available": False,
"diff_binary": False,
"diff_truncated": diff_truncated,
},
),
}
for file in (files if isinstance(files, list) else [])[:100]
if isinstance(file, dict) and isinstance(file.get("filename"), str)
]
normalized_reviews = [
{
"user": {
"login": (
review["user"].get("login", "")
if isinstance(review.get("user"), dict)
else ""
)
},
"state": review.get("state") or "COMMENT",
"body": review.get("body") or "",
}
for review in (reviews if isinstance(reviews, list) else [])[:50]
if isinstance(review, dict)
]
return {
"repository": repository,
"number": number,
"title": pull.get("title", ""),
"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(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 = []
elif not isinstance(events, list):
raise ValueError("Gitea activity feed response was not a list")
return [
{
"type": event.get("op_type", "activity"),
"actor": event.get("act_user") or {},
"repo": event.get("repo") or {},
"created_at": event.get("created", ""),
"type": (
event.get("op_type")
if isinstance(event.get("op_type"), str) and event.get("op_type")
else "activity"
),
"actor": (
event.get("act_user")
if isinstance(event.get("act_user"), dict)
else {}
),
"repo": (
event.get("repo")
if isinstance(event.get("repo"), dict)
else {}
),
"created_at": (
event.get("created")
if isinstance(event.get("created"), str)
else ""
),
}
for event in events
for event in (events or [])
if isinstance(event, dict)
]
+242 -28
View File
@@ -1,22 +1,100 @@
import asyncio
import math
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
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.gitea_proxy import activity_events, current_user, repos, issues, pull_requests
from src.models import User, Repo, Issue, PullRequest
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,
)
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):
"""Raised when Gitea returns a structurally invalid context payload."""
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,
@@ -30,6 +108,20 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
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", "/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
@app.get("/healthz")
def health() -> dict[str, str]:
"""Return process liveness without depending on Gitea."""
@@ -43,12 +135,20 @@ async def readiness():
user = await asyncio.wait_for(
current_user(), timeout=READINESS_TIMEOUT_SECONDS
)
if not isinstance(user, dict) or not user.get("login"):
raise ReadinessPayloadError(
"Gitea current-user response did not include a login"
)
except Exception as exc:
timed_out = isinstance(exc, TimeoutError)
error_message = (
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
if timed_out
else str(exc)
else (
str(exc)
if isinstance(exc, ReadinessPayloadError)
else "Gitea readiness check is temporarily unavailable"
)
)
return JSONResponse(
{
@@ -75,12 +175,26 @@ async def context() -> JSONResponse:
asyncio.gather(current_user(), repos(), issues(), pull_requests()),
timeout=CONTEXT_TIMEOUT_SECONDS,
)
if not isinstance(user_data, dict):
raise ContextPayloadError("Gitea current-user response was not an object")
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")
except Exception as e:
error_message = (
f"Gitea context request timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"
if isinstance(e, TimeoutError)
else str(e)
)
if isinstance(e, TimeoutError):
error_message = (
f"Gitea context request timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"
)
elif isinstance(e, ContextPayloadError):
error_message = str(e)
else:
error_message = "Gitea context is temporarily unavailable"
return JSONResponse({
"user": {"id": None, "login": "timmy", "full_name": "Timmy Jr", "email": ""},
"repos": [],
@@ -95,24 +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)
]
issue_models = [
Issue(id=i["id"], number=i["number"], title=i["title"], state=i["state"], labels=[l.get("name", "") for l in (i.get("labels") or [])], assignees=[a.get("login", "") for a in (i.get("assignees") or [])], url=i["html_url"])
for i in (issues_data or [])[:50]
if isinstance(i, dict)
]
pr_models = [
PullRequest(id=p["id"], number=p["number"], title=p["title"], state=p["state"], user=(p.get("user") or {}).get("login", ""), url=p["html_url"])
for p in (prs_data or [])[:50]
if isinstance(p, dict)
]
ctx = compute(user_model, repo_models, issue_models, pr_models)
return JSONResponse(ctx.model_dump())
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)))},
)
except Exception:
return 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")
@@ -146,3 +308,55 @@ 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):
async def load_requested_review():
repository = f"{owner}/{repo}"
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(
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"},
status_code=503,
)
+7
View File
@@ -24,6 +24,8 @@ class Issue(BaseModel):
state: str
labels: list[str] = []
assignees: list[str] = []
repository: str = ""
updated_at: str = ""
url: str
@@ -33,6 +35,11 @@ class PullRequest(BaseModel):
title: str
state: str
user: str
labels: list[str] = []
assignees: list[str] = []
work_reasons: list[str] = []
repository: str = ""
updated_at: str = ""
url: str
+30
View File
@@ -0,0 +1,30 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def empty_events(user_data=None):
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
monkeypatch.setattr(main, "activity_events", empty_events)
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", "/api/v1/live"):
response = await client.get(path)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
+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/",
}
+65
View File
@@ -0,0 +1,65 @@
import json
import subprocess
from pathlib import Path
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
def run_node(script: str) -> dict:
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_context_poller_is_single_flight_and_pauses_until_visible():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
let calls = 0;
let hidden = false;
let resolveRequest;
const timers = [];
const poller = createContextPoller({{
fetchContext: () => {{
calls += 1;
if (calls > 1) return Promise.resolve({{ repos: [] }});
return new Promise((resolve) => {{ resolveRequest = resolve; }});
}},
onSnapshot: () => {{}},
onError: () => {{}},
isHidden: () => hidden,
setTimer: (callback) => {{ timers.push(callback); return timers.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
const first = poller.start();
const joined = poller.refresh();
const callsWhilePending = calls;
resolveRequest({{ repos: [] }});
await Promise.all([first, joined]);
hidden = true;
poller.setVisible(false);
const scheduled = timers.shift();
if (scheduled) scheduled();
await Promise.resolve();
const callsWhileHidden = calls;
hidden = false;
await poller.setVisible(true);
process.stdout.write(JSON.stringify({{
callsWhilePending,
callsWhileHidden,
callsAfterResume: calls,
}}));
}})();
"""
assert run_node(script) == {
"callsWhilePending": 1,
"callsWhileHidden": 1,
"callsAfterResume": 2,
}
+241
View File
@@ -34,6 +34,27 @@ async def test_context_returns_fallback_when_gitea_exceeds_deadline(monkeypatch)
assert cancelled.is_set()
@pytest.mark.anyio
async def test_context_hides_upstream_exception_details(monkeypatch):
async def failing_user():
raise RuntimeError("connection refused by secret.internal.example")
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", failing_user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
body = bytes(response.body)
context = json.loads(body)
assert context["error"] == "Gitea context is temporarily unavailable"
assert b"secret.internal.example" not in body
@pytest.mark.anyio
async def test_context_normalizes_nullable_issue_collections(monkeypatch):
async def user():
@@ -65,6 +86,37 @@ async def test_context_normalizes_nullable_issue_collections(monkeypatch):
assert issue["assignees"] == []
@pytest.mark.anyio
async def test_context_skips_malformed_nested_issue_metadata(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def issue_collection():
return [{
"id": 85,
"number": 85,
"title": "Malformed nested metadata",
"state": "open",
"labels": [None, {"name": "bug"}, "invalid"],
"assignees": [None, {"login": "timmy"}, "invalid"],
"html_url": "https://forge.example/issues/85",
}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", issue_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
issue = json.loads(response.body)["issues"][0]
assert issue["labels"] == ["bug"]
assert issue["assignees"] == ["timmy"]
@pytest.mark.anyio
async def test_context_normalizes_nullable_repository_description(monkeypatch):
async def user():
@@ -122,6 +174,35 @@ async def test_context_normalizes_nullable_pull_request_author(monkeypatch):
assert pull_request["user"] == ""
@pytest.mark.anyio
async def test_context_normalizes_malformed_pull_request_author(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def pull_request_collection():
return [{
"id": 87,
"number": 87,
"title": "Malformed author",
"state": "open",
"user": "unknown",
"html_url": "https://forge.example/pulls/87",
}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
response = await main.context()
pull_request = json.loads(response.body)["pull_requests"][0]
assert pull_request["user"] == ""
@pytest.mark.anyio
async def test_context_normalizes_null_collection_payloads(monkeypatch):
async def user():
@@ -143,6 +224,29 @@ async def test_context_normalizes_null_collection_payloads(monkeypatch):
assert context["pull_requests"] == []
@pytest.mark.anyio
async def test_context_returns_fallback_for_malformed_collection_payloads(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def malformed_collection():
return {"message": "unexpected upstream shape"}
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", malformed_collection)
monkeypatch.setattr(main, "issues", malformed_collection)
monkeypatch.setattr(main, "pull_requests", malformed_collection)
response = await main.context()
context = json.loads(response.body)
assert response.status_code == 200
assert context["repos"] == []
assert context["issues"] == []
assert context["pull_requests"] == []
assert context["error"] == "Gitea collection response was not a list"
@pytest.mark.anyio
async def test_context_normalizes_nullable_user_profile_fields(monkeypatch):
async def user():
@@ -167,3 +271,140 @@ async def test_context_normalizes_nullable_user_profile_fields(monkeypatch):
assert response.status_code == 200
assert context["user"]["full_name"] == ""
assert context["user"]["email"] == ""
@pytest.mark.anyio
async def test_context_returns_fallback_for_null_current_user_payload(monkeypatch):
async def null_user():
return None
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", null_user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
context = json.loads(response.body)
assert response.status_code == 200
assert context["repos"] == []
assert context["error"] == "Gitea current-user response was not an object"
@pytest.mark.anyio
async def test_context_returns_fallback_for_incomplete_current_user_payload(monkeypatch):
async def incomplete_user():
return {"id": 1}
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", incomplete_user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
context = json.loads(response.body)
assert response.status_code == 200
assert context["repos"] == []
assert context["error"] == "Gitea current-user response did not include id and login"
@pytest.mark.anyio
async def test_context_skips_malformed_repository_entries(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def repository_collection():
return [
{"id": 79, "name": "missing-required-fields"},
{
"id": 1,
"name": "dashboard",
"full_name": "stackchain/dashboard",
"description": "Operations dashboard",
"html_url": "https://forge.example/stackchain/dashboard",
},
]
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", repository_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
repositories = json.loads(response.body)["repos"]
assert [repository["name"] for repository in repositories] == ["dashboard"]
@pytest.mark.anyio
async def test_context_skips_malformed_issue_entries(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def issue_collection():
return [
{"id": 81, "title": "missing-required-fields"},
{
"id": 1,
"number": 81,
"title": "Valid issue",
"state": "open",
"html_url": "https://forge.example/stackchain/dashboard/issues/81",
},
]
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", issue_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
issues = json.loads(response.body)["issues"]
assert [issue["title"] for issue in issues] == ["Valid issue"]
@pytest.mark.anyio
async def test_context_skips_malformed_pull_request_entries(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def pull_request_collection():
return [
{"id": 83, "title": "missing-required-fields"},
{
"id": 1,
"number": 83,
"title": "Valid pull request",
"state": "open",
"html_url": "https://forge.example/stackchain/stackchain-dashboard/pulls/83",
},
]
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
response = await main.context()
pull_requests = json.loads(response.body)["pull_requests"]
assert [pull_request["title"] for pull_request in pull_requests] == [
"Valid pull request"
]
+105 -2
View File
@@ -17,7 +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(loadEventStream, 5000)" 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
@@ -26,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
@@ -118,3 +119,105 @@ async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
"created_at": "2026-08-05T03:00:00Z",
}]
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():
return {"login": "timmy"}
async def fake_fetch(path):
return [
None,
{
"op_type": "push",
"act_user": {"login": "timmy"},
"repo": {"full_name": "stackchain/stackchain-dashboard"},
"created": "2026-08-06T08:00:00Z",
},
"invalid",
]
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
result = await gitea_proxy.activity_events()
assert result == [{
"type": "push",
"actor": {"login": "timmy"},
"repo": {"full_name": "stackchain/stackchain-dashboard"},
"created_at": "2026-08-06T08:00:00Z",
}]
@pytest.mark.anyio
async def test_activity_events_normalizes_null_feed_payload(monkeypatch):
async def fake_current_user():
return {"login": "timmy"}
async def fake_fetch(path):
return None
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
assert await gitea_proxy.activity_events() == []
@pytest.mark.anyio
async def test_event_stream_rejects_malformed_activity_feed_payload(monkeypatch):
async def fake_current_user():
return {"login": "timmy"}
async def fake_fetch(path):
return {"message": "unexpected upstream shape"}
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
response = await main.event_stream()
assert getattr(response, "status_code", None) == 503
assert json.loads(response.body) == {
"error": "Gitea event stream is temporarily unavailable"
}
@pytest.mark.anyio
async def test_activity_events_normalizes_malformed_nested_metadata(monkeypatch):
async def fake_current_user():
return {"login": "timmy"}
async def fake_fetch(path):
return [{
"op_type": None,
"act_user": "unknown",
"repo": None,
"created": None,
}]
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
assert await gitea_proxy.activity_events() == [{
"type": "activity",
"actor": {},
"repo": {},
"created_at": "",
}]
+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"]
+305
View File
@@ -0,0 +1,305 @@
import asyncio
import json
import pytest
from src import gitea_proxy
from src import main
@pytest.mark.anyio
async def test_work_collections_include_supported_review_request_search(monkeypatch):
requested_paths = []
async def fake_fetch(path):
requested_paths.append(path)
return []
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
assert await gitea_proxy.issues() == []
assert await gitea_proxy.pull_requests() == []
assert requested_paths == [
"repos/issues/search?state=open&assigned=true&type=issues&limit=50",
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50",
"repos/issues/search?state=open&review_requested=true&type=pulls&limit=50",
]
@pytest.mark.anyio
async def test_pull_requests_merge_assignment_and_review_responsibilities(monkeypatch):
assigned = {
"id": 11,
"number": 7,
"title": "Review API",
"repository": {"full_name": "stackchain/api"},
}
review_only = {
"id": 12,
"number": 8,
"title": "Review mobile",
"repository": {"full_name": "stackchain/mobile"},
}
async def fake_fetch(path):
if "assigned=true" in path:
return [assigned]
return [assigned.copy(), review_only]
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
pulls = await gitea_proxy.pull_requests()
assert [pull["id"] for pull in pulls] == [11, 12]
assert pulls[0]["work_reasons"] == ["assigned_to_me", "review_requested"]
assert pulls[1]["work_reasons"] == ["review_requested"]
@pytest.mark.anyio
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"},
},
{
"number": 8,
"repository": {"full_name": "stackchain/api"},
},
]
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", 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
async def test_context_preserves_repository_and_update_time_for_cross_repo_work(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def repositories():
return []
async def issues():
return [{
"id": 10,
"number": 7,
"title": "Ship mobile flow",
"state": "open",
"labels": [{"name": "P0"}],
"assignees": [{"login": "timmy"}],
"repository": {"full_name": "stackchain/mobile"},
"updated_at": "2026-08-06T12:00:00Z",
"html_url": "https://forge.example/stackchain/mobile/issues/7",
}]
async def pulls():
return [{
"id": 11,
"number": 7,
"title": "Review API",
"state": "open",
"user": {"login": "alex"},
"labels": [{"name": "priority-high"}],
"assignees": [{"login": "timmy"}],
"work_reasons": ["assigned_to_me", "review_requested"],
"repository": {"full_name": "stackchain/api"},
"updated_at": "2026-08-06T11:00:00Z",
"html_url": "https://forge.example/stackchain/api/pulls/7",
}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", repositories)
monkeypatch.setattr(main, "issues", issues)
monkeypatch.setattr(main, "pull_requests", pulls)
payload = json.loads((await main.context()).body)
assert payload["issues"][0]["repository"] == "stackchain/mobile"
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
assert payload["pull_requests"][0]["assignees"] == ["timmy"]
assert payload["pull_requests"][0]["work_reasons"] == [
"assigned_to_me",
"review_requested",
]
@pytest.mark.anyio
async def test_pull_review_detail_combines_pr_files_status_and_reviews(monkeypatch):
requested_paths = []
async def fake_fetch(path):
requested_paths.append(path)
if path.endswith("/pulls/7"):
return {
"title": "Review API",
"body": "Please check the retry flow.",
"html_url": "https://forge.example/stackchain/api/pulls/7",
"user": {"login": "alex"},
"head": {"sha": "abc123"},
}
if path.endswith("/files"):
return [
{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2},
None,
]
if "/commits/abc123/status" in path:
return {"state": "success"}
return [
{"user": {"login": "sam"}, "state": "APPROVED", "body": "Looks good"},
"malformed",
]
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
async def fake_fetch_text(path, max_bytes):
assert path == "repos/stackchain/api/pulls/7.diff"
assert max_bytes == gitea_proxy.REVIEW_DIFF_MAX_BYTES
return "", False
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
assert requested_paths == [
"repos/stackchain/api/pulls/7",
"repos/stackchain/api/pulls/7/files",
"repos/stackchain/api/commits/abc123/status",
"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"
assert len(detail["files"]) == 1
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):
if path.endswith("/pulls/7"):
return {
"title": "Review API",
"head": {"sha": "abc123"},
"user": {"login": "alex"},
}
if path.endswith("/files"):
return [
{"filename": "src/api.py", "status": "modified"},
{"filename": "assets/logo.png", "status": "modified"},
]
if path.endswith("/reviews"):
return []
return {"state": "success"}
diff = """diff --git a/src/api.py b/src/api.py
index 123..456 100644
--- a/src/api.py
+++ b/src/api.py
@@ -1,2 +1,3 @@
context
-old <value>
+new <value>
diff --git a/assets/logo.png b/assets/logo.png
Binary files a/assets/logo.png and b/assets/logo.png differ
"""
async def fake_fetch_text(path, max_bytes):
assert path == "repos/stackchain/api/pulls/7.diff"
return diff, True
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "fetch_text", fake_fetch_text)
detail = await gitea_proxy.pull_review_detail("stackchain/api", 7)
source, binary = detail["files"]
assert source["diff_lines"] == [
"@@ -1,2 +1,3 @@",
" context",
"-old <value>",
"+new <value>",
]
assert source["diff_truncated"] is True
assert source["diff_available"] is True
assert binary["diff_available"] is False
assert binary["diff_binary"] is True
+25 -4
View File
@@ -29,17 +29,38 @@ async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
@pytest.mark.anyio
async def test_readiness_endpoint_returns_503_when_gitea_is_unavailable(monkeypatch):
async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkeypatch):
async def unavailable_user():
raise RuntimeError("connection refused")
raise RuntimeError("secret internal upstream detail")
monkeypatch.setattr(main, "current_user", unavailable_user)
response = await main.readiness()
assert response.status_code == 503
assert b'"status":"not_ready"' in response.body
assert b'"error":"connection refused"' in response.body
assert json.loads(response.body) == {
"status": "not_ready",
"service": "stackchain-dashboard",
"error": "Gitea readiness check is temporarily unavailable",
}
assert b"secret internal upstream detail" not in response.body
@pytest.mark.anyio
async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monkeypatch):
async def null_user():
return None
monkeypatch.setattr(main, "current_user", null_user)
response = await main.readiness()
assert response.status_code == 503
assert json.loads(response.body) == {
"status": "not_ready",
"service": "stackchain-dashboard",
"error": "Gitea current-user response did not include a login",
}
@pytest.mark.anyio
+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
+569
View File
@@ -0,0 +1,569 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
payload = {
"user": {"login": "timmy"},
"issues": [
{
"id": 1,
"number": 7,
"title": "Assigned issue",
"state": "open",
"repository": "stackchain/mobile",
"labels": [],
"assignees": ["timmy"],
"updated_at": "2026-08-06T12:00:00Z",
"url": "https://forge.example/mobile/issues/7",
},
{
"id": 2,
"number": 7,
"title": "Priority issue",
"state": "open",
"repository": "stackchain/api",
"labels": ["P0"],
"assignees": [],
"updated_at": "2026-08-06T11:00:00Z",
"url": "https://forge.example/api/issues/7",
},
],
"pull_requests": [
{
"id": 3,
"number": 4,
"title": "Review PR",
"state": "open",
"repository": "stackchain/web",
"work_reasons": ["review_requested"],
"updated_at": "2026-08-06T13:00:00Z",
"url": "https://forge.example/web/pulls/4",
}
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const queue = buildMyWork({json.dumps(payload)});
process.stdout.write(JSON.stringify(queue));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
queue = json.loads(result.stdout)
assert [item["title"] for item in queue] == [
"Priority issue",
"Review PR",
"Assigned issue",
]
assert queue[0]["key"] == "stackchain/api#7"
assert queue[0]["reason"] == "P0 priority"
assert queue[1]["reason"] == "Needs your review"
assert queue[1]["is_review"] is True
assert queue[2]["reason"] == "Assigned to you"
def test_my_work_reviews_filter_and_summary_are_actionable():
items = [
{"title": "Issue", "kind": "issue", "is_review": False, "is_assigned": True},
{"title": "Assigned PR", "kind": "pull", "is_review": False, "is_assigned": True},
{"title": "Review PR", "kind": "pull", "is_review": True, "is_assigned": False},
]
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = {json.dumps(items)};
process.stdout.write(JSON.stringify({{
reviews: buildMyWork.filterMyWork(items, 'review'),
summary: buildMyWork.summarizeMyWork(items),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert [item["title"] for item in output["reviews"]] == ["Review PR"]
assert output["summary"] == "1 review · 2 assigned"
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
items = [
{"kind": "issue", "is_review": False},
{"kind": "pull", "is_review": False},
{"kind": "pull", "is_review": True},
]
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
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
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
assert html.index('id="my-work"') < html.index('data-panel-key="context"')
assert 'data-work-filter="all"' in html
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
assert "buildMyWork(data)" in html
assert "markMyWorkStale()" in html
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()
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
assert 'data-work-count="all"' in html
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
def test_review_controller_loads_encoded_cross_repo_detail_path():
script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
let request;
const controller = createReviewController({{ fetchJson: async (url, options) => {{
request = {{ url, accept: options.headers.Accept }};
return {{ title: 'Review API' }};
}} }});
controller.load({{ repository: 'stackchain/api', number: 7 }}).then(detail =>
process.stdout.write(JSON.stringify({{ request, title: detail.title }}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"request": {
"url": "api/v1/repos/stackchain/api/pulls/7/review",
"accept": "application/json",
},
"title": "Review API",
}
def test_review_diff_rows_escape_content_and_toggle_accessibly():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const html = reviewSheet.renderDiffFile({{
filename: 'src/<api>.py', status: 'modified', additions: 1, deletions: 1,
diff_available: true, diff_truncated: true,
diff_lines: ['@@ -1 +1 @@', '-old <token>', '+new & safe']
}}, 2, escapeHtml);
const button = {{ attrs: {{ 'aria-expanded': 'false' }}, getAttribute(k) {{ return this.attrs[k]; }}, setAttribute(k,v) {{ this.attrs[k]=v; }} }};
const panel = {{ hidden: true }};
reviewSheet.toggleDiff(button, panel);
process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expanded'], hidden: panel.hidden }}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert 'aria-expanded="false"' in output["html"]
assert 'src/&lt;api&gt;.py' in output["html"]
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()
assert 'id="review-sheet"' in html
assert 'role="dialog"' in html and 'aria-modal="true"' in html
assert 'id="review-sheet-status"' in html and 'aria-live="polite"' in html
assert 'id="open-review-gitea"' in html and 'rel="noopener noreferrer"' in html
assert '<script src="static/review-sheet.js"></script>' in html
assert '@media (max-width: 600px)' in html
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
@pytest.mark.anyio
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
html = await dashboard()
assert 'data-review-index' in html
assert "reviewController.load(selectedReview)" in html
assert "review-files" in html
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]
+88
View File
@@ -0,0 +1,88 @@
import asyncio
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_review_detail_endpoint_returns_normalized_no_store_payload(monkeypatch):
async def requested(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def detail(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {
"repository": repository,
"number": number,
"title": "Review API",
"body": "Check retries",
"url": "https://forge.example/stackchain/api/pulls/7",
"author": "alex",
"ci_state": "success",
"files": [{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2}],
"reviews": [{"user": {"login": "sam"}, "state": "APPROVED", "body": "Good"}],
}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main, "pull_review_detail", detail)
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 == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["files"][0]["filename"] == "src/api.py"
@pytest.mark.anyio
async def test_review_detail_rejects_pulls_not_requested_from_service_user(monkeypatch):
async def requested(repository, number):
return False
monkeypatch.setattr(main, "is_requested_review", requested)
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/private/secret/pulls/9/review")
assert response.status_code == 404
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)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={"action": "approve"},
)
assert response.status_code == 405
+29
View File
@@ -0,0 +1,29 @@
import pytest
from src.views import dashboard
@pytest.mark.anyio
async def test_one_context_snapshot_updates_every_context_backed_panel():
html = await dashboard()
assert '<script src="static/context-poller.js"></script>' in html
assert "createContextPoller({" in html
assert "renderContextSnapshot(data)" in html
assert "renderRepoMix(qs('#repo-mix'), data)" in html
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
@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
+9
View File
@@ -10,3 +10,12 @@ async def test_dashboard_renders_outside_repository_working_directory(monkeypatc
html = await dashboard()
assert "Stackchain Dashboard" in html
@pytest.mark.anyio
async def test_dashboard_viewport_allows_mobile_zoom():
html = await dashboard()
assert 'name="viewport"' in html
assert "width=device-width" in html
assert "user-scalable=no" not in html
+47
View File
@@ -1,11 +1,58 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
WIDGETS = Path(__file__).parents[1] / "frontend" / "widgets.js"
@pytest.mark.anyio
async def test_dashboard_renders_repo_mix_placeholder_before_javascript_runs():
html = await dashboard()
server_rendered_html = html.split("<script>", 1)[0]
assert '<div class="small" id="repo-mix">Loading…</div>' in server_rendered_html
@pytest.mark.anyio
async def test_dashboard_marks_fallback_context_as_degraded_instead_of_live():
html = await dashboard()
assert "data.error ? 'Degraded · ' + data.error : 'Live · updated just now'" in html
@pytest.mark.anyio
async def test_dashboard_does_not_offer_fabricated_work_when_context_fetch_fails():
html = await dashboard()
assert "Work items unavailable." in html
assert "Kickoff issue" not in html
assert "Live UI implementation" not in html
assert "Docker build and push" not in html
assert "Agent UI delta binding" not in html
def test_repo_mix_reports_unavailable_for_failed_context_response():
script = f"""
const updateRepoMix = require({json.dumps(str(WIDGETS))});
const element = {{ innerHTML: 'stale counts' }};
const failedFetch = async () => ({{
ok: false,
status: 503,
json: async () => ({{ error: 'upstream unavailable' }}),
}});
updateRepoMix(element, failedFetch).then(() => {{
if (!element.innerHTML.includes('Widget unavailable.')) {{
throw new Error(`expected unavailable state, got: ${{element.innerHTML}}`);
}}
}});
"""
subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)