Compare commits

...
Author SHA1 Message Date
rockachopa b5830cf86f Merge pull request 'Add review requests to the mobile My Work inbox' (#118) from timmy/117-mobile-review-inbox into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 15:53:13 +00:00
timmy b7d4bd8137 feat: add review requests to My Work (#117)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 15:52:26 +00:00
rockachopa 26ffa1f1b3 Merge pull request 'Add a mobile My Work inbox backed by supported Gitea search' (#116) from timmy/115-mobile-my-work-inbox into main
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Merge pull request 'Add a mobile My Work inbox backed by supported Gitea search' (#116) from timmy/115-mobile-my-work-inbox into main
2026-08-06 15:25:27 +00:00
timmy de2f8d8fcc feat: add mobile My Work inbox (#115)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 15:24:37 +00:00
rockachopa dce7373845 Merge pull request 'Hide upstream exception details from readiness responses' (#114) from timmy/113-hide-readiness-exception-details into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 14:48:42 +00:00
timmy 6be016d658 fix: hide readiness exception details (#113)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 14:47:52 +00:00
rockachopa 2891a0cfd8 Merge pull request 'Hide upstream exception details from fallback context responses' (#112) from timmy/111-hide-context-exception-details into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 14:18:13 +00:00
timmy f2540fb1db fix: hide context exception details (#111)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 14:17:30 +00:00
rockachopa 7f236c5a79 Merge pull request 'Prevent shared caches from storing live Gitea API responses' (#110) from timmy/109-prevent-shared-api-caching into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 13:48:14 +00:00
timmy 7c13bb1663 fix: prevent caching live API responses (#109)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 13:47:27 +00:00
rockachopa 37dd7710b4 Merge pull request 'Return retryable event response for malformed Gitea activity feed payload' (#108) from timmy/107-return-retryable-malformed-activity-feed into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 13:19:03 +00:00
timmy c21d968cc2 fix: reject malformed activity feed payloads (#107)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 13:17:35 +00:00
timmy d86e86e1fb Merge pull request 'Remove fabricated fallback work items' (#106) from timmy/105-do-not-display-fabricated-work-items into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Remove fabricated fallback work items (#106)
2026-08-06 12:47:54 +00:00
timmy bfdc042e38 fix: remove fabricated fallback work items (#105)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 12:47:12 +00:00
rockachopa acc60d11e7 Report repo mix context failures (#104)
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #103
2026-08-06 12:17:51 +00:00
timmy b055644be9 fix: report repo mix context failures (#103)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 12:17:13 +00:00
rockachopa 4602347a80 Merge pull request 'Allow mobile users to zoom the dashboard' (#102) from timmy/101-allow-mobile-dashboard-zoom into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 11:47:33 +00:00
timmy 64b6ff6c5b fix: allow mobile dashboard zoom (#101)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 11:46:46 +00:00
rockachopa 0427b3079e Merge pull request 'Return fallback for malformed Gitea collection payloads' (#100) from timmy/99-return-fallback-malformed-collection-payloads into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 11:17:42 +00:00
timmy f4e2969fec fix: handle malformed Gitea collections (#99)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 11:16:40 +00:00
rockachopa 7d5d1181c0 Merge pull request 'Show degraded status for fallback dashboard context' (#98)
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 10:47:58 +00:00
timmy b41d7ad03a fix: expose degraded fallback context status (#97)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 10:47:18 +00:00
rockachopa b2de3b7eeb Merge pull request 'Normalize malformed nested Gitea activity metadata' (#96) from timmy/95-normalize-malformed-nested-activity-metadata into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 10:17:39 +00:00
timmy 1b43116fe6 fix: normalize nested activity metadata (#95)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 10:16:51 +00:00
timmy 8f3bbde733 Merge pull request 'Normalize null Gitea activity feed payloads' (#94) from timmy/93-normalize-null-activity-feed into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 09:47:06 +00:00
timmy 6e24d7db17 fix: normalize null activity feed payloads (#93)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:46:24 +00:00
rockachopa 43a2804562 Merge pull request 'Return fallback context for incomplete current-user payload' (#92) from timmy/91-return-fallback-incomplete-current-user into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 09:18:02 +00:00
timmy 42b659e807 fix: fallback on incomplete current user (#91)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:17:16 +00:00
rockachopa 240d962ec3 Merge pull request 'Skip malformed Gitea activity feed entries' (#90) from timmy/89-skip-malformed-activity-feed-entries into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 5s
2026-08-06 09:01:36 +00:00
timmy 2aba489036 fix: skip malformed activity feed entries (#89)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:00:45 +00:00
rockachopa 10181aa019 Merge pull request 'Normalize malformed Gitea pull request authors' (#88) from timmy/87-normalize-malformed-gitea-pr-authors into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 08:18:28 +00:00
timmy 2d492d9d2e fix: normalize malformed pull request authors (#87)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 08:17:42 +00:00
timmy 1fe16c1342 Merge pull request 'Skip malformed nested Gitea issue metadata entries' (#86) from timmy/85-skip-malformed-nested-issue-metadata into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 07:48:49 +00:00
timmy 68c2048724 fix: skip malformed nested issue metadata (#85)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 07:48:04 +00:00
timmy fcdca7a37c Skip malformed Gitea pull request entries (#84)
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #83
2026-08-06 07:18:25 +00:00
timmy 77404cfd03 fix: skip malformed pull request entries (#83)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 07:17:40 +00:00
timmy 49ce2d9945 Merge pull request 'Skip malformed Gitea issue entries' (#82) from timmy/81-skip-malformed-gitea-issue-entries into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
Closes #81
2026-08-06 06:48:38 +00:00
timmy 5f86b58b88 fix: skip malformed issue entries (#81)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 06:47:47 +00:00
rockachopa 888c03e4c1 Merge pull request 'Skip malformed Gitea repository entries' (#80) from timmy/79-skip-malformed-repository-entries into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 06:19:24 +00:00
timmy bce46843b3 fix: skip malformed repository entries (#79)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 06:18:22 +00:00
rockachopa 89b14872e6 Merge pull request 'Return not-ready for invalid Gitea current-user payload' (#78) from timmy/77-return-not-ready-invalid-current-user into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 05:48:27 +00:00
timmy 090a059c17 fix: reject invalid readiness user payload (#77)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 05:47:38 +00:00
timmy b2f8eaf89a Merge pull request 'Handle null Gitea current-user payload' (#76) from timmy/75-handle-null-gitea-current-user-payload into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 05:24:39 +00:00
timmy 8001933692 fix: handle null Gitea current-user payload (#75)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 05:23:58 +00:00
timmy d9ad68b72b Merge pull request 'Normalize nullable Gitea user profile fields' (#74) from timmy/73-normalize-nullable-user-profile-fields into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 05:03:45 +00:00
14 changed files with 973 additions and 53 deletions

No files matched your search

+83 -34
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,23 @@ 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; }
.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: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; }
@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; }
}
.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 +89,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</button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues</button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs</button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews</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>
@@ -161,6 +193,8 @@ textarea { resize: vertical; min-height: 120px; }
<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>
(function(){
const qs = (s, el=document) => el.querySelector(s);
@@ -187,6 +221,8 @@ textarea { resize: vertical; min-height: 120px; }
});
});
let liveMode = true;
let selectedWorkFilter = 'all';
let lastMyWork = [];
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); }
@@ -199,6 +235,8 @@ textarea { resize: vertical; min-height: 120px; }
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
liveMode = 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>' +
@@ -216,32 +254,47 @@ textarea { resize: vertical; min-height: 120px; }
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
paintDeltas(data.deltas || []);
setStatus('Live · updated just now');
setStatus(data.error ? 'Degraded · ' + data.error : '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'},
]);
setStatus('Unavailable');
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);
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 =>
'<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' +
'<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>' : '') +
'</a>'
).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
}
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) {
const el = qs('#ai');
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
@@ -353,6 +406,15 @@ textarea { resize: vertical; min-height: 120px; }
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#refresh').addEventListener('click', load);
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.addEventListener('click', () => {
selectedWorkFilter = button.dataset.workFilter;
document.querySelectorAll('[data-work-filter]').forEach(item =>
item.setAttribute('aria-pressed', String(item === button))
);
renderMyWork();
});
});
load();
loadEventStream();
setInterval(load, 8000);
@@ -361,20 +423,7 @@ textarea { resize: vertical; min-height: 120px; }
tickWidgets();
/* Widgets */
async function updateRepoMix() {
const el = qs('#repo-mix');
try {
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
const data = await res.json();
const repos = data.repos || [];
const issues = (data.issues||[]).filter(i=>i.state==='open');
const prs = data.pull_requests || [];
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
} catch (e) {
el.innerHTML = '<div class="muted">Widget unavailable.</div>';
}
}
updateRepoMix();
updateRepoMix(qs('#repo-mix'), fetch);
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
+48
View File
@@ -0,0 +1,48 @@
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;
}
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.summarizeMyWork = summarizeMyWork;
module.exports = buildMyWork;
}
+19
View File
@@ -0,0 +1,19 @@
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);
const data = await response.json();
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>';
} catch (error) {
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = updateRepoMix;
}
+46 -7
View File
@@ -30,22 +30,61 @@ 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 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)
]
+109 -8
View File
@@ -18,6 +18,14 @@ 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,
allow_origins=["*"],
@@ -30,6 +38,14 @@ 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"}:
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/healthz")
def health() -> dict[str, str]:
"""Return process liveness without depending on Gitea."""
@@ -43,12 +59,20 @@ async def readiness():
user = await asyncio.wait_for(
current_user(), timeout=READINESS_TIMEOUT_SECONDS
)
if not isinstance(user, dict) or not user.get("login"):
raise ReadinessPayloadError(
"Gitea current-user response did not include a login"
)
except Exception as exc:
timed_out = isinstance(exc, TimeoutError)
error_message = (
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
if timed_out
else str(exc)
else (
str(exc)
if isinstance(exc, ReadinessPayloadError)
else "Gitea readiness check is temporarily unavailable"
)
)
return JSONResponse(
{
@@ -75,12 +99,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": [],
@@ -100,16 +138,79 @@ async def context() -> JSONResponse:
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") or [])], assignees=[a.get("login", "") for a in (i.get("assignees") or [])], url=i["html_url"])
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") or {}).get("login", ""), url=p["html_url"])
PullRequest(
id=p["id"],
number=p["number"],
title=p["title"],
state=p["state"],
user=(
p["user"].get("login", "")
if isinstance(p.get("user"), dict)
else ""
),
labels=[
label.get("name", "")
for label in (p.get("labels") or [])
if isinstance(label, dict)
],
assignees=[
assignee.get("login", "")
for assignee in (p.get("assignees") or [])
if isinstance(assignee, dict)
],
work_reasons=[
reason
for reason in (p.get("work_reasons") or [])
if reason in ("assigned_to_me", "review_requested")
],
repository=(
p["repository"].get("full_name", "")
if isinstance(p.get("repository"), dict)
else ""
),
updated_at=p.get("updated_at") or "",
url=p["html_url"],
)
for p in (prs_data or [])[:50]
if isinstance(p, dict)
and all(
field in p
for field in ("id", "number", "title", "state", "html_url")
)
]
ctx = compute(user_model, repo_models, issue_models, pr_models)
return JSONResponse(ctx.model_dump())
+7
View File
@@ -24,6 +24,8 @@ class Issue(BaseModel):
state: str
labels: list[str] = []
assignees: list[str] = []
repository: str = ""
updated_at: str = ""
url: str
@@ -33,6 +35,11 @@ class PullRequest(BaseModel):
title: str
state: str
user: str
labels: list[str] = []
assignees: list[str] = []
work_reasons: list[str] = []
repository: str = ""
updated_at: str = ""
url: str
+30
View File
@@ -0,0 +1,30 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def empty_events():
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"
+241
View File
@@ -34,6 +34,27 @@ async def test_context_returns_fallback_when_gitea_exceeds_deadline(monkeypatch)
assert cancelled.is_set()
@pytest.mark.anyio
async def test_context_hides_upstream_exception_details(monkeypatch):
async def failing_user():
raise RuntimeError("connection refused by secret.internal.example")
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", failing_user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
body = bytes(response.body)
context = json.loads(body)
assert context["error"] == "Gitea context is temporarily unavailable"
assert b"secret.internal.example" not in body
@pytest.mark.anyio
async def test_context_normalizes_nullable_issue_collections(monkeypatch):
async def user():
@@ -65,6 +86,37 @@ async def test_context_normalizes_nullable_issue_collections(monkeypatch):
assert issue["assignees"] == []
@pytest.mark.anyio
async def test_context_skips_malformed_nested_issue_metadata(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def issue_collection():
return [{
"id": 85,
"number": 85,
"title": "Malformed nested metadata",
"state": "open",
"labels": [None, {"name": "bug"}, "invalid"],
"assignees": [None, {"login": "timmy"}, "invalid"],
"html_url": "https://forge.example/issues/85",
}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", issue_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
issue = json.loads(response.body)["issues"][0]
assert issue["labels"] == ["bug"]
assert issue["assignees"] == ["timmy"]
@pytest.mark.anyio
async def test_context_normalizes_nullable_repository_description(monkeypatch):
async def user():
@@ -122,6 +174,35 @@ async def test_context_normalizes_nullable_pull_request_author(monkeypatch):
assert pull_request["user"] == ""
@pytest.mark.anyio
async def test_context_normalizes_malformed_pull_request_author(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def pull_request_collection():
return [{
"id": 87,
"number": 87,
"title": "Malformed author",
"state": "open",
"user": "unknown",
"html_url": "https://forge.example/pulls/87",
}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
response = await main.context()
pull_request = json.loads(response.body)["pull_requests"][0]
assert pull_request["user"] == ""
@pytest.mark.anyio
async def test_context_normalizes_null_collection_payloads(monkeypatch):
async def user():
@@ -143,6 +224,29 @@ async def test_context_normalizes_null_collection_payloads(monkeypatch):
assert context["pull_requests"] == []
@pytest.mark.anyio
async def test_context_returns_fallback_for_malformed_collection_payloads(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def malformed_collection():
return {"message": "unexpected upstream shape"}
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", malformed_collection)
monkeypatch.setattr(main, "issues", malformed_collection)
monkeypatch.setattr(main, "pull_requests", malformed_collection)
response = await main.context()
context = json.loads(response.body)
assert response.status_code == 200
assert context["repos"] == []
assert context["issues"] == []
assert context["pull_requests"] == []
assert context["error"] == "Gitea collection response was not a list"
@pytest.mark.anyio
async def test_context_normalizes_nullable_user_profile_fields(monkeypatch):
async def user():
@@ -167,3 +271,140 @@ async def test_context_normalizes_nullable_user_profile_fields(monkeypatch):
assert response.status_code == 200
assert context["user"]["full_name"] == ""
assert context["user"]["email"] == ""
@pytest.mark.anyio
async def test_context_returns_fallback_for_null_current_user_payload(monkeypatch):
async def null_user():
return None
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", null_user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
context = json.loads(response.body)
assert response.status_code == 200
assert context["repos"] == []
assert context["error"] == "Gitea current-user response was not an object"
@pytest.mark.anyio
async def test_context_returns_fallback_for_incomplete_current_user_payload(monkeypatch):
async def incomplete_user():
return {"id": 1}
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", incomplete_user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
context = json.loads(response.body)
assert response.status_code == 200
assert context["repos"] == []
assert context["error"] == "Gitea current-user response did not include id and login"
@pytest.mark.anyio
async def test_context_skips_malformed_repository_entries(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def repository_collection():
return [
{"id": 79, "name": "missing-required-fields"},
{
"id": 1,
"name": "dashboard",
"full_name": "stackchain/dashboard",
"description": "Operations dashboard",
"html_url": "https://forge.example/stackchain/dashboard",
},
]
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", repository_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
repositories = json.loads(response.body)["repos"]
assert [repository["name"] for repository in repositories] == ["dashboard"]
@pytest.mark.anyio
async def test_context_skips_malformed_issue_entries(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def issue_collection():
return [
{"id": 81, "title": "missing-required-fields"},
{
"id": 1,
"number": 81,
"title": "Valid issue",
"state": "open",
"html_url": "https://forge.example/stackchain/dashboard/issues/81",
},
]
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", issue_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
issues = json.loads(response.body)["issues"]
assert [issue["title"] for issue in issues] == ["Valid issue"]
@pytest.mark.anyio
async def test_context_skips_malformed_pull_request_entries(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def pull_request_collection():
return [
{"id": 83, "title": "missing-required-fields"},
{
"id": 1,
"number": 83,
"title": "Valid pull request",
"state": "open",
"html_url": "https://forge.example/stackchain/stackchain-dashboard/pulls/83",
},
]
async def empty_collection():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
response = await main.context()
pull_requests = json.loads(response.body)["pull_requests"]
assert [pull_request["title"] for pull_request in pull_requests] == [
"Valid pull request"
]
+87
View File
@@ -118,3 +118,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": "",
}]
+108
View File
@@ -0,0 +1,108 @@
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_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",
]
+25 -4
View File
@@ -29,17 +29,38 @@ async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
@pytest.mark.anyio
async def test_readiness_endpoint_returns_503_when_gitea_is_unavailable(monkeypatch):
async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkeypatch):
async def unavailable_user():
raise RuntimeError("connection refused")
raise RuntimeError("secret internal upstream detail")
monkeypatch.setattr(main, "current_user", unavailable_user)
response = await main.readiness()
assert response.status_code == 503
assert b'"status":"not_ready"' in response.body
assert b'"error":"connection refused"' in response.body
assert json.loads(response.body) == {
"status": "not_ready",
"service": "stackchain-dashboard",
"error": "Gitea readiness check is temporarily unavailable",
}
assert b"secret internal upstream detail" not in response.body
@pytest.mark.anyio
async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monkeypatch):
async def null_user():
return None
monkeypatch.setattr(main, "current_user", null_user)
response = await main.readiness()
assert response.status_code == 503
assert json.loads(response.body) == {
"status": "not_ready",
"service": "stackchain-dashboard",
"error": "Gitea current-user response did not include a login",
}
@pytest.mark.anyio
+114
View File
@@ -0,0 +1,114 @@
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"
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"
@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
+9
View File
@@ -10,3 +10,12 @@ async def test_dashboard_renders_outside_repository_working_directory(monkeypatc
html = await dashboard()
assert "Stackchain Dashboard" in html
@pytest.mark.anyio
async def test_dashboard_viewport_allows_mobile_zoom():
html = await dashboard()
assert 'name="viewport"' in html
assert "width=device-width" in html
assert "user-scalable=no" not in html
+47
View File
@@ -1,11 +1,58 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
WIDGETS = Path(__file__).parents[1] / "frontend" / "widgets.js"
@pytest.mark.anyio
async def test_dashboard_renders_repo_mix_placeholder_before_javascript_runs():
html = await dashboard()
server_rendered_html = html.split("<script>", 1)[0]
assert '<div class="small" id="repo-mix">Loading…</div>' in server_rendered_html
@pytest.mark.anyio
async def test_dashboard_marks_fallback_context_as_degraded_instead_of_live():
html = await dashboard()
assert "data.error ? 'Degraded · ' + data.error : 'Live · updated just now'" in html
@pytest.mark.anyio
async def test_dashboard_does_not_offer_fabricated_work_when_context_fetch_fails():
html = await dashboard()
assert "Work items unavailable." in html
assert "Kickoff issue" not in html
assert "Live UI implementation" not in html
assert "Docker build and push" not in html
assert "Agent UI delta binding" not in html
def test_repo_mix_reports_unavailable_for_failed_context_response():
script = f"""
const updateRepoMix = require({json.dumps(str(WIDGETS))});
const element = {{ innerHTML: 'stale counts' }};
const failedFetch = async () => ({{
ok: false,
status: 503,
json: async () => ({{ error: 'upstream unavailable' }}),
}});
updateRepoMix(element, failedFetch).then(() => {{
if (!element.innerHTML.includes('Widget unavailable.')) {{
throw new Error(`expected unavailable state, got: ${{element.innerHTML}}`);
}}
}});
"""
subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)