Compare commits

...
Author SHA1 Message Date
timmy 6c4ce5cf48 feat: preview mobile review diffs (#123)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 17:31:40 +00:00
rockachopa f662361b09 Merge pull request 'Add a mobile review readiness inspector' (#122) from timmy/121-mobile-review-completion into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 17:10:37 +00:00
timmy 83788c6b82 feat: add mobile review readiness inspector (#121)
CI / lint (pull_request) Successful in 14s
CI / build-frontend (pull_request) Successful in 6s
2026-08-06 17:09:40 +00:00
rockachopa 329591ebd6 Make mobile My Work refresh resumable and single-flight (#120)
CI / lint (push) Successful in 12s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 6s
Closes #119
2026-08-06 16:35:13 +00:00
timmy 32d9d9413f feat: make My Work refresh resumable (#119)
CI / lint (pull_request) Successful in 15s
CI / build-frontend (pull_request) Successful in 6s
2026-08-06 16:30:01 +00:00
13 changed files with 893 additions and 67 deletions

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
function createContextPoller({
fetchContext,
onSnapshot,
onError,
isHidden = () => false,
setTimer = setTimeout,
clearTimer = clearTimeout,
intervalMs = 8000,
}) {
let inFlight = null;
let timer = null;
let stopped = false;
function cancelTimer() {
if (timer !== null) clearTimer(timer);
timer = null;
}
function schedule() {
cancelTimer();
if (stopped || isHidden()) return;
timer = setTimer(() => {
timer = null;
refresh();
}, intervalMs);
}
function refresh() {
if (stopped || isHidden()) return Promise.resolve(null);
if (inFlight) return inFlight;
let request;
try {
request = fetchContext();
} catch (error) {
request = Promise.reject(error);
}
inFlight = Promise.resolve(request)
.then((snapshot) => {
onSnapshot(snapshot);
return snapshot;
})
.catch((error) => {
onError(error);
return null;
})
.finally(() => {
inFlight = null;
schedule();
});
return inFlight;
}
function setVisible(visible) {
cancelTimer();
if (!visible) return Promise.resolve(null);
return refresh();
}
return {
start: refresh,
refresh,
setVisible,
stop() {
stopped = true;
cancelTimer();
},
};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createContextPoller;
}
+180 -57
View File
@@ -56,20 +56,37 @@ textarea { resize: vertical; min-height: 120px; }
.event:last-child { border-bottom: 0; }
.my-work { grid-column: 1 / -1; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.work-filters { display:flex; gap:8px; }
.work-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; }
.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; }
@@ -96,10 +113,10 @@ textarea { resize: vertical; min-height: 120px; }
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
</div>
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All</button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues</button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs</button>
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews</button>
<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>
@@ -189,12 +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);
@@ -221,56 +259,86 @@ 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;
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');
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(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
setClock();
} catch (e) {
console.error('context failed', e);
liveMode = false;
setStatus('Unavailable');
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();
}
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.';
@@ -279,14 +347,63 @@ textarea { resize: vertical; min-height: 120px; }
function renderMyWork() {
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item =>
'<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' +
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>' : '') +
'</a>'
).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
(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() {
@@ -326,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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return escapeHtml(s); }
@@ -404,27 +512,42 @@ textarea { resize: vertical; min-height: 120px; }
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
const contextPoller = createContextPoller({
fetchContext: fetchContextSnapshot,
onSnapshot: renderContextSnapshot,
onError: handleContextError,
isHidden: () => document.hidden,
intervalMs: 8000,
});
function load() { return contextPoller.refresh(); }
qs('#refresh').addEventListener('click', 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();
});
});
load();
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 */
updateRepoMix(qs('#repo-mix'), fetch);
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
})();
+10
View File
@@ -41,8 +41,18 @@ function summarizeMyWork(items) {
return reviewLabel + ' · ' + assignedLabel;
}
function countMyWork(items) {
return {
all: items.length,
issue: items.filter((item) => item.kind === 'issue').length,
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
review: items.filter((item) => item.is_review).length,
};
}
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.countMyWork = countMyWork;
module.exports = buildMyWork;
}
+58
View File
@@ -0,0 +1,58 @@
function createReviewController({ fetchJson }) {
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
throw new Error('This review link is invalid.');
}
return 'api/v1/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) +
'/pulls/' + Number(item.number) + '/review';
}
async function load(item) {
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
return { load };
}
function diffLineClass(line) {
if (line.startsWith('@@')) return 'hunk';
if (line.startsWith('+')) return 'added';
if (line.startsWith('-')) return 'removed';
return 'context';
}
function renderDiffFile(file, index, escapeHtml) {
const panelId = 'review-diff-' + index;
let preview;
if (file.diff_available) {
const lines = (file.diff_lines || []).map(line =>
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
escapeHtml(String(line)) + '</span>'
).join('');
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
'</pre>';
} else {
const message = file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.';
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
}
return '<div class="review-file"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
'<strong>' + escapeHtml(file.filename || 'Unknown file') + '</strong><span class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' +
Number(file.deletions || 0) + '</span></button>' + preview + '</div>';
}
function toggleDiff(button, panel) {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded;
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.toggleDiff = toggleDiff;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
}
+9 -5
View File
@@ -1,19 +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);
const data = await response.json();
const repos = data.repos || [];
const issues = (data.issues || []).filter((issue) => issue.state === 'open');
const pullRequests = data.pull_requests || [];
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
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;
}
+140
View File
@@ -1,10 +1,13 @@
import os
import shlex
from typing import Any
import httpx
GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
REVIEW_DIFF_MAX_BYTES = 64 * 1024
REVIEW_DIFF_MAX_LINES = 400
def _auth() -> dict[str, str]:
@@ -21,6 +24,66 @@ async def fetch(path: str) -> Any:
return r.json()
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
chunks: list[bytes] = []
size = 0
truncated = False
async with httpx.AsyncClient(base_url=GITEA_URL, timeout=10) as client:
async with client.stream(
"GET", f"/api/v1/{path}", headers={**_auth(), "Accept": "text/plain"}
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
remaining = max_bytes - size
if len(chunk) > remaining:
chunks.append(chunk[:remaining])
truncated = True
break
chunks.append(chunk)
size += len(chunk)
return b"".join(chunks).decode("utf-8", errors="replace"), truncated
def _diff_previews(diff: str, stream_truncated: bool) -> dict[str, dict]:
previews: dict[str, dict] = {}
current: dict | None = None
in_hunk = False
remaining = REVIEW_DIFF_MAX_LINES
for line in diff.splitlines():
if line.startswith("diff --git "):
try:
target = shlex.split(line)[3]
filename = target[2:] if target.startswith("b/") else target
except (IndexError, ValueError):
current = None
continue
current = {
"diff_lines": [],
"diff_available": False,
"diff_binary": False,
"diff_truncated": stream_truncated,
}
previews[filename] = current
in_hunk = False
continue
if current is None:
continue
if line.startswith("Binary files ") or line == "GIT binary patch":
current["diff_binary"] = True
in_hunk = False
continue
if line.startswith("@@"):
in_hunk = True
if in_hunk and not line.startswith("\\ No newline at end of file"):
if remaining:
current["diff_lines"].append(line)
current["diff_available"] = True
remaining -= 1
else:
current["diff_truncated"] = True
return previews
async def current_user() -> dict:
return await fetch("user")
@@ -55,6 +118,83 @@ async def pull_requests() -> list[dict]:
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")
+37 -4
View File
@@ -2,13 +2,21 @@ 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
@@ -41,7 +49,10 @@ 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"}:
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
@@ -247,3 +258,25 @@ async def event_stream():
)
},
)
@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,
)
+65
View File
@@ -0,0 +1,65 @@
import json
import subprocess
from pathlib import Path
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
def run_node(script: str) -> dict:
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_context_poller_is_single_flight_and_pauses_until_visible():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
let calls = 0;
let hidden = false;
let resolveRequest;
const timers = [];
const poller = createContextPoller({{
fetchContext: () => {{
calls += 1;
if (calls > 1) return Promise.resolve({{ repos: [] }});
return new Promise((resolve) => {{ resolveRequest = resolve; }});
}},
onSnapshot: () => {{}},
onError: () => {{}},
isHidden: () => hidden,
setTimer: (callback) => {{ timers.push(callback); return timers.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
const first = poller.start();
const joined = poller.refresh();
const callsWhilePending = calls;
resolveRequest({{ repos: [] }});
await Promise.all([first, joined]);
hidden = true;
poller.setVisible(false);
const scheduled = timers.shift();
if (scheduled) scheduled();
await Promise.resolve();
const callsWhileHidden = calls;
hidden = false;
await poller.setVisible(true);
process.stdout.write(JSON.stringify({{
callsWhilePending,
callsWhileHidden,
callsAfterResume: calls,
}}));
}})();
"""
assert run_node(script) == {
"callsWhilePending": 1,
"callsWhileHidden": 1,
"callsAfterResume": 2,
}
+2 -1
View File
@@ -17,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
+126
View File
@@ -53,6 +53,29 @@ async def test_pull_requests_merge_assignment_and_review_responsibilities(monkey
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():
@@ -106,3 +129,106 @@ async def test_context_preserves_repository_and_update_time_for_cross_repo_work(
"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
+115
View File
@@ -8,6 +8,7 @@ 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():
@@ -97,6 +98,24 @@ process.stdout.write(JSON.stringify({{
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()
@@ -112,3 +131,99 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
assert "buildMyWork(data)" in html
assert "markMyWorkStale()" in html
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
@pytest.mark.anyio
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
html = await dashboard()
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
assert 'data-work-count="all"' in html
assert 'data-work-count="issue"' in html
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
def test_review_controller_loads_encoded_cross_repo_detail_path():
script = f"""
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
let request;
const controller = createReviewController({{ fetchJson: async (url, options) => {{
request = {{ url, accept: options.headers.Accept }};
return {{ title: 'Review API' }};
}} }});
controller.load({{ repository: 'stackchain/api', number: 7 }}).then(detail =>
process.stdout.write(JSON.stringify({{ request, title: detail.title }}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"request": {
"url": "api/v1/repos/stackchain/api/pulls/7/review",
"accept": "application/json",
},
"title": "Review API",
}
def test_review_diff_rows_escape_content_and_toggle_accessibly():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
const html = reviewSheet.renderDiffFile({{
filename: 'src/<api>.py', status: 'modified', additions: 1, deletions: 1,
diff_available: true, diff_truncated: true,
diff_lines: ['@@ -1 +1 @@', '-old <token>', '+new & safe']
}}, 2, escapeHtml);
const button = {{ attrs: {{ 'aria-expanded': 'false' }}, getAttribute(k) {{ return this.attrs[k]; }}, setAttribute(k,v) {{ this.attrs[k]=v; }} }};
const panel = {{ hidden: true }};
reviewSheet.toggleDiff(button, panel);
process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expanded'], hidden: panel.hidden }}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert 'aria-expanded="false"' in output["html"]
assert 'src/&lt;api&gt;.py' in output["html"]
assert '-old &lt;token&gt;' in output["html"]
assert '+new &amp; safe' in output["html"]
assert 'Preview truncated' in output["html"]
assert output["expanded"] == "true"
assert output["hidden"] is False
@pytest.mark.anyio
async def test_review_requests_open_an_accessible_mobile_detail_sheet():
html = await dashboard()
assert 'id="review-sheet"' in html
assert 'role="dialog"' in html and 'aria-modal="true"' in html
assert 'id="review-sheet-status"' in html and 'aria-live="polite"' in html
assert 'id="open-review-gitea"' in html and 'rel="noopener noreferrer"' in html
assert '<script src="static/review-sheet.js"></script>' in html
assert '@media (max-width: 600px)' in html
assert '.review-sheet-panel' in html and 'width:100%' in html
assert '.review-action' in html and 'min-height:44px' in html
assert '.review-file-toggle' in html and 'min-height:44px' in html
assert '.review-diff' in html and 'overflow-x:auto' in html
@pytest.mark.anyio
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
html = await dashboard()
assert 'data-review-index' in html
assert "reviewController.load(selectedReview)" in html
assert "review-files" in html
assert "review-history" in html
assert "open-review-gitea" in html
assert "reviewController.submit" not in html
+61
View File
@@ -0,0 +1,61 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_review_detail_endpoint_returns_normalized_no_store_payload(monkeypatch):
async def requested(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def detail(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {
"repository": repository,
"number": number,
"title": "Review API",
"body": "Check retries",
"url": "https://forge.example/stackchain/api/pulls/7",
"author": "alex",
"ci_state": "success",
"files": [{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2}],
"reviews": [{"user": {"login": "sam"}, "state": "APPROVED", "body": "Good"}],
}
monkeypatch.setattr(main, "is_requested_review", requested)
monkeypatch.setattr(main, "pull_review_detail", detail)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/pulls/7/review")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["files"][0]["filename"] == "src/api.py"
@pytest.mark.anyio
async def test_review_detail_rejects_pulls_not_requested_from_service_user(monkeypatch):
async def requested(repository, number):
return False
monkeypatch.setattr(main, "is_requested_review", requested)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/private/secret/pulls/9/review")
assert response.status_code == 404
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_public_review_endpoint_does_not_expose_service_token_mutations():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/review",
json={"action": "approve"},
)
assert response.status_code == 405
+17
View File
@@ -0,0 +1,17 @@
import pytest
from src.views import dashboard
@pytest.mark.anyio
async def test_one_context_snapshot_updates_every_context_backed_panel():
html = await dashboard()
assert '<script src="static/context-poller.js"></script>' in html
assert "createContextPoller({" in html
assert "renderContextSnapshot(data)" in html
assert "renderRepoMix(qs('#repo-mix'), data)" in html
assert "setInterval(tickWidgets, 2000)" not in html
assert "updateRepoMix(qs('#repo-mix'), fetch)" not in html
assert "setInterval(load, 8000)" not in html
assert "document.addEventListener('visibilitychange'" in html