Compare commits

...
Author SHA1 Message Date
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
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 9s
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
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 8s
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
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 9s
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)
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 8s
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
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 7s
2026-08-06 05:03:45 +00:00
timmy 902db45741 fix: normalize nullable user profile fields (#73)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 05:03:04 +00:00
rockachopa af7f2ff92a Merge pull request 'Bound readiness probe latency' (#72) from timmy/71-bound-readiness-probe-latency 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 04:18:48 +00:00
timmy dea3024f83 fix: bound readiness probe latency (#71)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 04:17:59 +00:00
timmy e46113a51a Merge pull request 'Handle null Gitea collection payloads' (#70) from timmy/69-handle-null-gitea-collection-payloads into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 7s
2026-08-06 03:48:48 +00:00
timmy d8f2248600 fix: normalize null Gitea collection payloads (#69)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 03:47:48 +00:00
rockachopa d5ee4dcb29 Merge pull request 'Return retryable event response when Gitea request fails' (#68) from timmy/67-retry-event-stream-upstream-failures into main
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 7s
2026-08-06 03:18:27 +00:00
timmy 19da7f47df fix: return retryable event stream upstream failures (#67)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 03:17:40 +00:00
rockachopa 069d8979fb Merge pull request 'Resolve dashboard API requests under subpath' (#66) from timmy/65-resolve-dashboard-api-requests-under-subpath 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 02:48:28 +00:00
timmy 13fcd42f13 fix: resolve dashboard API requests under subpath (#65)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 02:47:38 +00:00
timmy 5ca87c1b7c Merge pull request 'Fix markdown renderer under dashboard subpath' (#64) from timmy/63-resolve-markdown-renderer-under-dashboard-subpath into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Merge pull request 'Fix markdown renderer under dashboard subpath' (#64)
2026-08-06 02:18:28 +00:00
timmy 4bbe02b3f6 fix: resolve markdown renderer under dashboard subpath (#63)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 02:17:46 +00:00
rockachopa fb200ceb93 Merge pull request 'Resolve command script under dashboard subpath' (#62) from timmy/60-fix-command-assets-under-dashboard-subpath into main
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 7s
2026-08-06 01:53:33 +00:00
timmy 2fdb9fb897 fix: resolve command script under dashboard subpath (#60)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 01:52:54 +00:00
rockachopa b119dcf47d Merge pull request 'Fix filtered command palette action selection' (#61) from timmy/60-fix-filtered-command-palette-selection 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 01:49:03 +00:00
timmy 3e1ce3e72a fix: preserve filtered command actions (#60)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 01:48:09 +00:00
timmy 36f500d58b Merge pull request 'fix: Bound event stream refresh latency' (#59) from timmy/58-bound-event-stream-refresh-latency 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 01:19:22 +00:00
timmy a173182158 fix: bound event stream refresh latency (#58)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 01:18:41 +00:00
rockachopa 609df470d6 Merge pull request 'fix: Normalize nullable pull request authors' (#57) from timmy/56-handle-nullable-pull-request-authors 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 00:48:38 +00:00
timmy a92109b65f fix: normalize nullable pull request authors (#56)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 00:47:54 +00:00
rockachopa 6678358e4c Merge pull request 'fix: Normalize nullable repository descriptions' (#55)
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
Closes #54
2026-08-06 00:18:39 +00:00
timmy 6a22bc212d fix: normalize nullable repository descriptions (#54)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 00:17:59 +00:00
rockachopa 60b6f1a74d Merge pull request 'fix: Escape markdown preview HTML' (#53) from timmy/52-escape-markdown-preview-html into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 5s
2026-08-05 23:49:10 +00:00
timmy f63c8c39b1 fix: escape markdown preview HTML (#52)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-05 23:48:27 +00:00
rockachopa a6732b9c93 Merge pull request 'fix: Handle nullable Gitea issue metadata' (#51) from timmy/50-handle-nullable-gitea-issue-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-05 23:19:18 +00:00
timmy e8b5fd31a5 fix: handle nullable Gitea issue metadata (#50)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-05 23:18:32 +00:00
rockachopa 352d70e859 Merge pull request 'fix: Render repo mix before populating counts' (#49) from timmy/48-fix-repo-mix-widget-initialization-race into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Merge pull request 'fix: Render repo mix before populating counts' (#49) from timmy/48-fix-repo-mix-widget-initialization-race into main
2026-08-05 22:49:54 +00:00
timmy 89c22c6091 fix: render repo mix before populating counts (#48)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-05 22:48:47 +00:00
rockachopa e837934bec Merge pull request 'fix: Serve dashboard independently of process working directory' (#47) from timmy/46-serve-dashboard-independent-of-cwd into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 9s
2026-08-05 22:18:23 +00:00
timmy f0dc260c9b fix: serve dashboard independently of process cwd (#46)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 22:17:33 +00:00
rockachopa 94cdd0f407 Merge pull request 'feat: Stop tracked Python bytecode from dirtying autonomous worktree' (#45) from timmy/44-stop-tracked-python-bytecode-from-dirtying-auton into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-05 15:39:42 +00:00
timmy 9b912fc837 fix: stop tracking Python bytecode
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 15:38:50 +00:00
rockachopa d7bdbd3e18 Merge pull request 'feat: Add realtime Gitea event stream widget' (#43) from timmy/41-add-realtime-gitea-event-stream-widget into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 10s
2026-08-05 15:34:56 +00:00
timmy 4afa4032d7 fix: preserve Gitea events on refresh failure (#41)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 15:34:05 +00:00
rockachopa 582ab559c7 Merge pull request 'feat: Fallback context endpoint timeout' (#42) from timmy/40-fallback-context-endpoint-timeout into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-05 15:04:17 +00:00
34 changed files with 2339 additions and 150 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
(function (root, factory) {
const filterCommands = factory();
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
if (root) root.filterCommands = filterCommands;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function filterCommands(commands, filter) {
const term = String(filter || '').toLowerCase();
return commands.filter(command => command.name.toLowerCase().includes(term));
};
});
+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;
}
+263 -101
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,40 @@ 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:block; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.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-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; }
@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 +106,21 @@ 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>
</div>
</div>
<div class="my-work-list" id="my-work-list"></div>
</section>
<aside class="sidebar">
<details class="panel stack" data-panel-key="context" open>
<summary><h2>Context &amp; view</h2></summary>
@@ -106,7 +155,16 @@ textarea { resize: vertical; min-height: 120px; }
</details>
<details class="panel" data-panel-key="widgets" open>
<summary><h2>Live Widgets</h2></summary>
<div id="widgets" class="stack"></div>
<div id="widgets" class="stack">
<div class="widget">
<h3>Live clock</h3>
<div class="small" id="widget-clock">—</div>
</div>
<div class="widget">
<h3>Repo mix</h3>
<div class="small" id="repo-mix">Loading…</div>
</div>
</div>
</details>
</section>
@@ -148,8 +206,33 @@ 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>
<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>
<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);
@@ -176,59 +259,157 @@ 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'].includes(savedFilter)) selectedWorkFilter = savedFilter;
} catch (e) {
console.warn('Could not restore My Work filter', e);
}
let lastMyWork = [];
let hasContextSnapshot = false;
let selectedReview = null;
let reviewTrigger = null;
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 fetchContextSnapshot() {
const res = await fetch('api/v1/context', { 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>');
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 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 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>');
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'},
]);
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);
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 or review requests.';
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' : 'Issue') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
if (item.is_review) {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
return '<button class="my-work-card review-trigger" data-review-index="' + index + '">' + contents + '</button>';
}
return '<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>';
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', () => openReviewSheet(lastMyWork[Number(button.dataset.reviewIndex)], button));
});
}
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('#review-sheet-body').textContent = '';
qs('#review-files').textContent = '';
qs('#review-history').textContent = '';
qs('#open-review-gitea').href = item.url;
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);
});
});
qs('#review-history').innerHTML = (detail.reviews || []).length ? detail.reviews.map(review =>
'<div class="review-history"><strong>' + escapeHtml(review.user?.login || 'Reviewer') + '</strong> · ' +
escapeHtml(review.state || 'commented') + (review.body ? '<div class="small">' + escapeHtml(review.body) + '</div>' : '') + '</div>'
).join('') : '<div>No prior reviews.</div>';
qs('#review-sheet-status').textContent = 'Ready to review · by ' + (detail.author || 'unknown author');
} catch (error) {
qs('#review-sheet-status').textContent = error.message + ' Use Open in Gitea or close and retry.';
}
}
function closeReviewSheet() {
qs('#review-sheet').classList.remove('open');
selectedReview = null;
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 paintDeltas(deltas) {
@@ -253,24 +434,15 @@ textarea { resize: vertical; min-height: 120px; }
async function loadEventStream() {
setEventStreamStatus('Updating…');
try {
const res = await fetch('/api/v1/events', { headers: { Accept: 'application/json' } });
const res = await fetch('api/v1/events', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
paintEventStream(await res.json());
setEventStreamStatus('Updated ' + fmt(new Date()));
} catch (e) {
qs('#gitea-events').innerHTML = '<div class="muted">Event stream unavailable.</div>';
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); }
@@ -318,14 +490,7 @@ textarea { resize: vertical; min-height: 120px; }
qs('#md-input').addEventListener('input', renderMD);
function renderMD() {
const raw = qs('#md-input').value || '';
const html = raw.replace(/^#{1,6}\s.*$/gm, (m)=>{ const lvl=m.match(/^(#{1,6})/)[1].length; return '<h'+lvl+'>'+m.replace(/^#{1,6}\s/,'')+'</h'+lvl+'>'; })
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/^\-\s?(.+)$/gm,'<li>$1</li>')
.replace(/(<li>.*<\/li>)/s,'<ul>$1</ul>')
.replace(/\n/g,'<br>');
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + html + '</div>';
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
}
/* Commands */
@@ -337,55 +502,52 @@ textarea { resize: vertical; min-height: 120px; }
];
function renderCommands(filter) {
const el = qs('#cmd-results');
const term = String(filter||'').toLowerCase();
const items = commands.filter(c => c.name.toLowerCase().includes(term));
const items = filterCommands(commands, filter);
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => { commands[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
});
}
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
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);
const contextPoller = createContextPoller({
fetchContext: fetchContextSnapshot,
onSnapshot: renderContextSnapshot,
onError: handleContextError,
isHidden: () => document.hidden,
intervalMs: 8000,
});
function load() { return contextPoller.refresh(); }
qs('#refresh').addEventListener('click', load);
load();
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();
loadEventStream();
setInterval(load, 8000);
setInterval(loadEventStream, 5000);
setInterval(tickWidgets, 2000);
tickWidgets();
setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000);
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
if (!document.hidden) loadEventStream();
});
/* Widgets */
const widgetOrder = ['countdown','repoAtAGlance'];
const widgetRegistry = {
countdown: () => ({ title: 'Live clock', html: '<div class="small" id="widget-clock">—</div>' }),
repoAtAGlance: async () => {
const el = qs('#repo-mix');
if (!el) return { title:'Repo mix', html:'<div class="small">Unavailable</div>' };
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>'; }
return { title:'Repo mix', html:'<div class="small" id="repo-mix">Loading…</div>' };
},
};
const widgetsEl = qs('#widgets');
function paintWidgets() {
widgetsEl.innerHTML = '';
widgetOrder.forEach(async k => {
const w = await widgetRegistry[k]();
const node = document.createElement('div'); node.className='widget'; node.innerHTML = '<h3>' + escapeHtml(w.title) + '</h3>' + w.html;
widgetsEl.appendChild(node);
});
}
paintWidgets();
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
})();
+29
View File
@@ -0,0 +1,29 @@
(function (root, factory) {
const renderMarkdown = factory();
if (typeof module === 'object' && module.exports) module.exports = renderMarkdown;
root.renderMarkdown = renderMarkdown;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character]);
}
return function renderMarkdown(raw) {
return escapeHtml(raw)
.replace(/^#{1,6}\s.*$/gm, (line) => {
const level = line.match(/^(#{1,6})/)[1].length;
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
})
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br>');
};
});
+58
View File
@@ -0,0 +1,58 @@
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'];
return 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,
reason: priorityLabel ? priorityLabel + ' priority' :
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
_priority: priorityLabel ? 0 : (isReview ? 1 : (assigned ? 2 : 3)),
};
}).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 filterMyWork(items, selectedFilter) {
if (selectedFilter === 'all') return items;
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
return items.filter((item) => item.kind === selectedFilter);
}
function summarizeMyWork(items) {
const reviews = items.filter((item) => item.is_review).length;
const assigned = items.filter((item) => item.is_assigned).length;
const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's');
const assignedLabel = assigned + ' assigned';
return 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,
};
}
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
module.exports = buildMyWork;
}
+58
View File
@@ -0,0 +1,58 @@
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>';
}
return '<div class="review-file"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
'<strong>' + escapeHtml(file.filename || 'Unknown file') + '</strong><span class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' +
Number(file.deletions || 0) + '</span></button>' + preview + '</div>';
}
function toggleDiff(button, panel) {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded;
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.toggleDiff = toggleDiff;
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;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+186 -7
View File
@@ -1,10 +1,13 @@
import os
import shlex
from typing import Any
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
def _auth() -> dict[str, str]:
@@ -21,6 +24,66 @@ async def fetch(path: str) -> Any:
return r.json()
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
chunks: list[bytes] = []
size = 0
truncated = False
async with httpx.AsyncClient(base_url=GITEA_URL, timeout=10) as client:
async with client.stream(
"GET", f"/api/v1/{path}", headers={**_auth(), "Accept": "text/plain"}
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
remaining = max_bytes - size
if len(chunk) > remaining:
chunks.append(chunk[:remaining])
truncated = True
break
chunks.append(chunk)
size += len(chunk)
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:
return await fetch("user")
@@ -30,22 +93,138 @@ 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"
)
async def pull_requests() -> list[dict]:
return await fetch("user/pulls?limit=50")
assigned = await fetch(
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50"
)
review_requested = await 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 is_requested_review(repository: str, number: int) -> bool:
pulls = await pull_requests()
return any(
isinstance(pull, dict)
and pull.get("number") == number
and "review_requested" in (pull.get("work_reasons") or [])
and isinstance(pull.get("repository"), dict)
and pull["repository"].get("full_name") == repository
for pull in pulls
)
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")
files = await fetch(f"{base}/files")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
status = await fetch(f"repos/{repository}/commits/{sha}/status")
reviews = await fetch(f"{base}/reviews")
diff, diff_truncated = await fetch_text(
f"repos/{repository}/pulls/{number}.diff", REVIEW_DIFF_MAX_BYTES
)
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", ""),
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"files": normalized_files,
"reviews": normalized_reviews,
}
async def activity_events() -> list[dict]:
user = await current_user()
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)
]
+198 -27
View File
@@ -1,18 +1,38 @@
import asyncio
import math
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
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.gitea_proxy import (
activity_events,
current_user,
is_requested_review,
issues,
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")
CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
class ContextPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid context payload."""
class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload."""
app.add_middleware(
CORSMiddleware,
@@ -22,9 +42,21 @@ app.add_middleware(
allow_headers=["*"],
)
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"} or (
request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/review")
):
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/healthz")
def health() -> dict[str, str]:
"""Return process liveness without depending on Gitea."""
@@ -35,15 +67,34 @@ def health() -> dict[str, str]:
async def readiness():
"""Return readiness after verifying the configured Gitea connection."""
try:
user = await current_user()
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)
if isinstance(exc, ReadinessPayloadError)
else "Gitea readiness check is temporarily unavailable"
)
)
return JSONResponse(
{
"status": "not_ready",
"service": "stackchain-dashboard",
"error": str(exc),
"error": error_message,
},
status_code=503,
headers={
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
} if timed_out else None,
)
return {
"status": "ready",
@@ -59,12 +110,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": [],
@@ -79,33 +144,139 @@ 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", ""), email=user_data.get("email", ""))
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", ""), url=r["html_url"], updated_at=r.get("updated_at", ""))
for r in repo_data[:50]
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=[l.get("name", "") for l in i.get("labels", [])], assignees=[a.get("login", "") for a in i.get("assignees", [])], url=i["html_url"])
for i in issues_data[:50]
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.get("user", {}).get("login", ""), url=p["html_url"])
for p in prs_data[:50]
PullRequest(
id=p["id"],
number=p["number"],
title=p["title"],
state=p["state"],
user=(
p["user"].get("login", "")
if isinstance(p.get("user"), dict)
else ""
),
labels=[
label.get("name", "")
for label in (p.get("labels") or [])
if isinstance(label, dict)
],
assignees=[
assignee.get("login", "")
for assignee in (p.get("assignees") or [])
if isinstance(assignee, dict)
],
work_reasons=[
reason
for reason in (p.get("work_reasons") or [])
if reason in ("assigned_to_me", "review_requested")
],
repository=(
p["repository"].get("full_name", "")
if isinstance(p.get("repository"), dict)
else ""
),
updated_at=p.get("updated_at") or "",
url=p["html_url"],
)
for p in (prs_data or [])[:50]
if isinstance(p, dict)
and all(
field in p
for field in ("id", "number", "title", "state", "html_url")
)
]
ctx = compute(user_model, repo_models, issue_models, pr_models)
return JSONResponse({
"user": ctx.user.dict(),
"repos": [r.dict() for r in ctx.repos],
"issues": [i.dict() for i in ctx.issues],
"pull_requests": [p.dict() for p in ctx.pull_requests],
"view": ctx.view,
"deltas": [d.dict() for d in ctx.deltas],
})
return JSONResponse(ctx.model_dump())
@app.get("/api/v1/events")
async def event_stream() -> list[dict]:
return await activity_events()
async def event_stream():
try:
return await asyncio.wait_for(
activity_events(), timeout=EVENT_STREAM_TIMEOUT_SECONDS
)
except TimeoutError:
return JSONResponse(
{
"error": (
"Gitea event stream request timed out after "
f"{EVENT_STREAM_TIMEOUT_SECONDS:g}s"
)
},
status_code=503,
headers={
"Retry-After": str(
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
)
},
)
except Exception:
return JSONResponse(
{"error": "Gitea event stream is temporarily unavailable"},
status_code=503,
headers={
"Retry-After": str(
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
)
},
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review")
async def review_detail(owner: str, repo: str, number: int):
try:
repository = f"{owner}/{repo}"
if not await asyncio.wait_for(
is_requested_review(repository, number),
timeout=CONTEXT_TIMEOUT_SECONDS,
):
raise HTTPException(status_code=404, detail="Review request not found")
return await asyncio.wait_for(
pull_review_detail(repository, number),
timeout=CONTEXT_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Pull request review details are temporarily unavailable"},
status_code=503,
)
+15 -8
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,20 +35,25 @@ 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
class SuggestionDelta(BaseModel):
panel: str
action: str
target: str = ""
priority: str = "medium"
class ViewContext(BaseModel):
user: User
repos: list[Repo] = []
issues: list[Issue] = []
pull_requests: list[PullRequest] = []
view: str = "dashboard"
deltas: list[dict] = []
class SuggestionDelta(BaseModel):
panel: str
action: str
target: str = ""
priority: str = "medium"
deltas: list[SuggestionDelta] = []
+4 -1
View File
@@ -1,9 +1,12 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
@router.get("/", response_class=HTMLResponse)
async def dashboard() -> str:
return open("frontend/index.html").read()
return DASHBOARD_FILE.read_text()
Binary file not shown.
+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():
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"):
response = await client.get(path)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
+20
View File
@@ -0,0 +1,20 @@
import re
from pathlib import Path
from urllib.parse import urljoin
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
def test_api_requests_resolve_inside_dashboard_subpath():
html = DASHBOARD_HTML.read_text()
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
assert api_paths
assert {
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",
}
+48
View File
@@ -0,0 +1,48 @@
import json
import subprocess
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin
FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js"
class ScriptSourceParser(HTMLParser):
def __init__(self):
super().__init__()
self.sources = []
def handle_starttag(self, tag, attrs):
if tag == "script":
source = dict(attrs).get("src")
if source:
self.sources.append(source)
def test_filtered_command_runs_the_matching_action():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
let action = '';
const commands = [
{{ name: 'Open whiteboard', run: () => {{ action = 'whiteboard'; }} }},
{{ name: 'Refresh now', run: () => {{ action = 'refresh'; }} }},
];
const matches = filterCommands(commands, 'refresh');
if (matches.length !== 1) throw new Error(`expected one match, got ${{matches.length}}`);
matches[0].run();
if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_command_script_resolves_inside_dashboard_subpath():
parser = ScriptSourceParser()
parser.feed((FRONTEND / "index.html").read_text())
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", command_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
+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,
}
+378 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import pytest
@@ -30,4 +31,380 @@ async def test_context_returns_fallback_when_gitea_exceeds_deadline(monkeypatch)
assert response.headers["retry-after"] == "1"
assert b'"repos":[]' in response.body
assert b'"error":"Gitea context request timed out after 0.01s"' in response.body
assert cancelled.is_set()
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():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def issue_collection():
return [{
"id": 50,
"number": 50,
"title": "Nullable metadata",
"state": "open",
"labels": None,
"assignees": None,
"html_url": "https://forge.example/issues/50",
}]
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"] == []
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():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def repo_collection():
return [{
"id": 54,
"name": "dashboard",
"full_name": "stackchain/dashboard",
"description": None,
"html_url": "https://forge.example/stackchain/dashboard",
}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", repo_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
repository = json.loads(response.body)["repos"][0]
assert repository["description"] == ""
@pytest.mark.anyio
async def test_context_normalizes_nullable_pull_request_author(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def pull_request_collection():
return [{
"id": 56,
"number": 56,
"title": "Nullable author",
"state": "open",
"user": None,
"html_url": "https://forge.example/pulls/56",
}]
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_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():
return {"id": 1, "login": "timmy"}
async def null_collection():
return None
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", null_collection)
monkeypatch.setattr(main, "issues", null_collection)
monkeypatch.setattr(main, "pull_requests", null_collection)
response = await main.context()
context = json.loads(response.body)
assert context["repos"] == []
assert context["issues"] == []
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():
return {
"id": 1,
"login": "timmy",
"full_name": None,
"email": None,
}
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", empty_collection)
response = await main.context()
context = json.loads(response.body)
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"
]
+137 -1
View File
@@ -1,3 +1,5 @@
import asyncio
import json
from pathlib import Path
import pytest
@@ -15,7 +17,8 @@ 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 "setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000)" in html
assert "if (!document.hidden) loadEventStream();" in html
assert "event.actor?.login" in html
assert "event.repo?.full_name" in html
@@ -28,6 +31,13 @@ def test_event_stream_reports_when_activity_was_refreshed():
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
html = DASHBOARD.read_text()
assert "setEventStreamStatus('Update failed · showing last activity')" in html
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
@pytest.mark.anyio
async def test_event_stream_returns_recent_authenticated_gitea_activity(monkeypatch):
expected = [{"type": "create", "actor": {"login": "timmy"}}]
@@ -42,6 +52,45 @@ async def test_event_stream_returns_recent_authenticated_gitea_activity(monkeypa
assert response == expected
@pytest.mark.anyio
async def test_event_stream_returns_retryable_response_when_gitea_exceeds_deadline(monkeypatch):
cancelled = asyncio.Event()
async def hanging_activity_events():
try:
await asyncio.Event().wait()
finally:
cancelled.set()
monkeypatch.setattr(main, "EVENT_STREAM_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main, "activity_events", hanging_activity_events)
response = await main.event_stream()
assert response.status_code == 503
assert response.headers["retry-after"] == "1"
assert json.loads(response.body) == {
"error": "Gitea event stream request timed out after 0.01s"
}
assert cancelled.is_set()
@pytest.mark.anyio
async def test_event_stream_returns_retryable_response_when_gitea_request_fails(monkeypatch):
async def failing_activity_events():
raise ConnectionError("connection refused")
monkeypatch.setattr(main, "activity_events", failing_activity_events)
response = await main.event_stream()
assert response.status_code == 503
assert response.headers["retry-after"] == "5"
assert json.loads(response.body) == {
"error": "Gitea event stream is temporarily unavailable"
}
@pytest.mark.anyio
async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
paths = []
@@ -70,3 +119,90 @@ 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_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": "",
}]
+234
View File
@@ -0,0 +1,234 @@
import pytest
import json
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_requested_review_guard_matches_repository_number_and_reason(monkeypatch):
async def pulls():
return [
{
"number": 7,
"repository": {"full_name": "stackchain/api"},
"work_reasons": ["review_requested"],
},
{
"number": 8,
"repository": {"full_name": "stackchain/api"},
"work_reasons": ["assigned_to_me"],
},
]
monkeypatch.setattr(gitea_proxy, "pull_requests", pulls)
assert await gitea_proxy.is_requested_review("stackchain/api", 7) is True
assert await gitea_proxy.is_requested_review("stackchain/api", 8) is False
assert await gitea_proxy.is_requested_review("stackchain/private", 7) is False
@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["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_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
+54 -4
View File
@@ -1,3 +1,6 @@
import asyncio
import json
import pytest
from src import main
@@ -26,14 +29,61 @@ 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
async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monkeypatch):
cancelled = asyncio.Event()
async def hanging_user():
try:
await asyncio.sleep(0.05)
return {"login": "too-late"}
finally:
cancelled.set()
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
monkeypatch.setattr(main, "current_user", hanging_user)
response = await main.readiness()
assert response.status_code == 503
assert response.headers["retry-after"] == "1"
assert json.loads(response.body) == {
"status": "not_ready",
"service": "stackchain-dashboard",
"error": "Gitea readiness check timed out after 0.01s",
}
assert cancelled.is_set()
+52
View File
@@ -0,0 +1,52 @@
import json
import subprocess
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin
FRONTEND = Path(__file__).parent.parent / "frontend"
RENDERER = FRONTEND / "markdown.js"
class ScriptSourceParser(HTMLParser):
def __init__(self):
super().__init__()
self.sources = []
def handle_starttag(self, tag, attrs):
if tag == "script":
source = dict(attrs).get("src")
if source:
self.sources.append(source)
def test_markdown_renderer_escapes_raw_html_before_rendering_heading():
payload = "# <img src=x onerror=alert(document.domain)>"
script = (
f"const render = require({json.dumps(str(RENDERER))});"
f"process.stdout.write(render({json.dumps(payload)}));"
)
result = subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
)
assert result.stdout == (
"<h1>&lt;img src=x onerror=alert(document.domain)&gt;</h1>"
)
def test_markdown_script_resolves_inside_dashboard_subpath():
parser = ScriptSourceParser()
parser.feed((FRONTEND / "index.html").read_text())
markdown_source = next(
source for source in parser.sources if source.endswith("markdown.js")
)
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
+229
View File
@@ -0,0 +1,229 @@
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}
@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 '.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_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 '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 output["expanded"] == "true"
assert output["hidden"] is 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-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
+18
View File
@@ -0,0 +1,18 @@
import subprocess
def test_python_bytecode_is_not_tracked():
tracked = subprocess.run(
["git", "ls-files"],
check=True,
capture_output=True,
text=True,
).stdout.splitlines()
bytecode = [
path
for path in tracked
if "__pycache__" in path.split("/") or path.endswith((".pyc", ".pyo"))
]
assert bytecode == []
+61
View File
@@ -0,0 +1,61 @@
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_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
+17
View File
@@ -0,0 +1,17 @@
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
+21
View File
@@ -0,0 +1,21 @@
import pytest
from src.views import dashboard
@pytest.mark.anyio
async def test_dashboard_renders_outside_repository_working_directory(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
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
+58
View File
@@ -0,0 +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
)