Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c4ce5cf48 | ||
|
|
f662361b09 | ||
|
|
83788c6b82 | ||
|
|
329591ebd6 | ||
|
|
32d9d9413f | ||
|
|
b5830cf86f | ||
|
|
b7d4bd8137 | ||
|
|
26ffa1f1b3 | ||
|
|
de2f8d8fcc | ||
|
|
dce7373845 | ||
|
|
6be016d658 | ||
|
|
2891a0cfd8 | ||
|
|
f2540fb1db | ||
|
|
7f236c5a79 | ||
|
|
7c13bb1663 | ||
|
|
37dd7710b4 | ||
|
|
c21d968cc2 | ||
|
|
d86e86e1fb | ||
|
|
bfdc042e38 | ||
|
|
acc60d11e7 | ||
|
|
b055644be9 | ||
|
|
4602347a80 | ||
|
|
64b6ff6c5b | ||
|
|
0427b3079e | ||
|
|
f4e2969fec | ||
|
|
7d5d1181c0 | ||
|
|
b41d7ad03a | ||
|
|
b2de3b7eeb | ||
|
|
1b43116fe6 | ||
|
|
8f3bbde733 | ||
|
|
6e24d7db17 | ||
|
|
43a2804562 | ||
|
|
42b659e807 | ||
|
|
240d962ec3 | ||
|
|
2aba489036 | ||
|
|
10181aa019 | ||
|
|
2d492d9d2e | ||
|
|
1fe16c1342 | ||
|
|
68c2048724 | ||
|
|
fcdca7a37c | ||
|
|
77404cfd03 | ||
|
|
49ce2d9945 | ||
|
|
5f86b58b88 | ||
|
|
888c03e4c1 | ||
|
|
bce46843b3 | ||
|
|
89b14872e6 | ||
|
|
090a059c17 | ||
|
|
b2f8eaf89a | ||
|
|
8001933692 | ||
|
|
d9ad68b72b | ||
|
|
902db45741 | ||
|
|
af7f2ff92a | ||
|
|
dea3024f83 | ||
|
|
e46113a51a | ||
|
|
d8f2248600 | ||
|
|
d5ee4dcb29 | ||
|
|
19da7f47df | ||
|
|
069d8979fb | ||
|
|
13fcd42f13 | ||
|
|
5ca87c1b7c | ||
|
|
4bbe02b3f6 | ||
|
|
fb200ceb93 | ||
|
|
2fdb9fb897 | ||
|
|
b119dcf47d | ||
|
|
3e1ce3e72a | ||
|
|
36f500d58b | ||
|
|
a173182158 | ||
|
|
609df470d6 | ||
|
|
a92109b65f | ||
|
|
6678358e4c | ||
|
|
6a22bc212d | ||
|
|
60b6f1a74d | ||
|
|
f63c8c39b1 |
No files matched your search
@@ -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));
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+252
-86
@@ -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 & view</h2></summary>
|
||||
@@ -157,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);
|
||||
@@ -185,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) {
|
||||
@@ -262,7 +434,7 @@ 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()));
|
||||
@@ -271,15 +443,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
}
|
||||
}
|
||||
|
||||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function escAttr(s) { return escapeHtml(s); }
|
||||
@@ -327,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 */
|
||||
@@ -346,42 +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 */
|
||||
async function updateRepoMix() {
|
||||
const el = qs('#repo-mix');
|
||||
try {
|
||||
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
const data = await res.json();
|
||||
const repos = data.repos || [];
|
||||
const issues = (data.issues||[]).filter(i=>i.state==='open');
|
||||
const prs = data.pull_requests || [];
|
||||
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
updateRepoMix();
|
||||
|
||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||
setInterval(widgetTick, 1000);
|
||||
})();
|
||||
|
||||
@@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[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>');
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+186
-7
@@ -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)
|
||||
]
|
||||
+197
-19
@@ -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,26 +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") or [])], assignees=[a.get("login", "") for a in (i.get("assignees") or [])], 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(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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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():
|
||||
@@ -62,4 +83,328 @@ async def test_context_normalizes_nullable_issue_collections(monkeypatch):
|
||||
issue = json.loads(response.body)["issues"][0]
|
||||
|
||||
assert issue["labels"] == []
|
||||
assert issue["assignees"] == []
|
||||
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"
|
||||
]
|
||||
+130
-1
@@ -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
|
||||
|
||||
@@ -49,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 = []
|
||||
@@ -77,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": "",
|
||||
}]
|
||||
@@ -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
@@ -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()
|
||||
@@ -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><img src=x onerror=alert(document.domain)></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"
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
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/<api>.py' in output["html"]
|
||||
assert '-old <token>' in output["html"]
|
||||
assert '+new & 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
Reference in new issue
Block a user