Compare commits

...
Author SHA1 Message Date
timmy 902db45741 fix: normalize nullable user profile fields (#73)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 05:03:04 +00:00
rockachopa af7f2ff92a Merge pull request 'Bound readiness probe latency' (#72) from timmy/71-bound-readiness-probe-latency into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 04:18:48 +00:00
timmy dea3024f83 fix: bound readiness probe latency (#71)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 04:17:59 +00:00
timmy e46113a51a Merge pull request 'Handle null Gitea collection payloads' (#70) from timmy/69-handle-null-gitea-collection-payloads into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 03:48:48 +00:00
timmy d8f2248600 fix: normalize null Gitea collection payloads (#69)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 03:47:48 +00:00
rockachopa d5ee4dcb29 Merge pull request 'Return retryable event response when Gitea request fails' (#68) from timmy/67-retry-event-stream-upstream-failures into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 03:18:27 +00:00
timmy 19da7f47df fix: return retryable event stream upstream failures (#67)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 03:17:40 +00:00
rockachopa 069d8979fb Merge pull request 'Resolve dashboard API requests under subpath' (#66) from timmy/65-resolve-dashboard-api-requests-under-subpath into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 02:48:28 +00:00
timmy 13fcd42f13 fix: resolve dashboard API requests under subpath (#65)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 02:47:38 +00:00
timmy 5ca87c1b7c Merge pull request 'Fix markdown renderer under dashboard subpath' (#64) from timmy/63-resolve-markdown-renderer-under-dashboard-subpath into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Merge pull request 'Fix markdown renderer under dashboard subpath' (#64)
2026-08-06 02:18:28 +00:00
timmy 4bbe02b3f6 fix: resolve markdown renderer under dashboard subpath (#63)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 02:17:46 +00:00
rockachopa fb200ceb93 Merge pull request 'Resolve command script under dashboard subpath' (#62) from timmy/60-fix-command-assets-under-dashboard-subpath into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 01:53:33 +00:00
timmy 2fdb9fb897 fix: resolve command script under dashboard subpath (#60)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 01:52:54 +00:00
rockachopa b119dcf47d Merge pull request 'Fix filtered command palette action selection' (#61) from timmy/60-fix-filtered-command-palette-selection into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 01:49:03 +00:00
timmy 3e1ce3e72a fix: preserve filtered command actions (#60)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 01:48:09 +00:00
timmy 36f500d58b Merge pull request 'fix: Bound event stream refresh latency' (#59) from timmy/58-bound-event-stream-refresh-latency into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 01:19:22 +00:00
timmy a173182158 fix: bound event stream refresh latency (#58)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 01:18:41 +00:00
rockachopa 609df470d6 Merge pull request 'fix: Normalize nullable pull request authors' (#57) from timmy/56-handle-nullable-pull-request-authors into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 00:48:38 +00:00
timmy a92109b65f fix: normalize nullable pull request authors (#56)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 00:47:54 +00:00
rockachopa 6678358e4c Merge pull request 'fix: Normalize nullable repository descriptions' (#55)
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
Closes #54
2026-08-06 00:18:39 +00:00
timmy 6a22bc212d fix: normalize nullable repository descriptions (#54)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 00:17:59 +00:00
rockachopa 60b6f1a74d Merge pull request 'fix: Escape markdown preview HTML' (#53) from timmy/52-escape-markdown-preview-html into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 5s
2026-08-05 23:49:10 +00:00
timmy f63c8c39b1 fix: escape markdown preview HTML (#52)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-05 23:48:27 +00:00
rockachopa a6732b9c93 Merge pull request 'fix: Handle nullable Gitea issue metadata' (#51) from timmy/50-handle-nullable-gitea-issue-metadata into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-05 23:19:18 +00:00
timmy e8b5fd31a5 fix: handle nullable Gitea issue metadata (#50)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-05 23:18:32 +00:00
rockachopa 352d70e859 Merge pull request 'fix: Render repo mix before populating counts' (#49) from timmy/48-fix-repo-mix-widget-initialization-race into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Merge pull request 'fix: Render repo mix before populating counts' (#49) from timmy/48-fix-repo-mix-widget-initialization-race into main
2026-08-05 22:49:54 +00:00
timmy 89c22c6091 fix: render repo mix before populating counts (#48)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-05 22:48:47 +00:00
rockachopa e837934bec Merge pull request 'fix: Serve dashboard independently of process working directory' (#47) from timmy/46-serve-dashboard-independent-of-cwd into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-05 22:18:23 +00:00
timmy f0dc260c9b fix: serve dashboard independently of process cwd (#46)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 22:17:33 +00:00
rockachopa 94cdd0f407 Merge pull request 'feat: Stop tracked Python bytecode from dirtying autonomous worktree' (#45) from timmy/44-stop-tracked-python-bytecode-from-dirtying-auton into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-05 15:39:42 +00:00
timmy 9b912fc837 fix: stop tracking Python bytecode
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 15:38:50 +00:00
rockachopa d7bdbd3e18 Merge pull request 'feat: Add realtime Gitea event stream widget' (#43) from timmy/41-add-realtime-gitea-event-stream-widget into main
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-05 15:34:56 +00:00
timmy 4afa4032d7 fix: preserve Gitea events on refresh failure (#41)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 15:34:05 +00:00
rockachopa 582ab559c7 Merge pull request 'feat: Fallback context endpoint timeout' (#42) from timmy/40-fallback-context-endpoint-timeout into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-05 15:04:17 +00:00
timmy 965b24a183 fix: advertise retry delay on context timeout (#40)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 15:03:27 +00:00
timmy 9ea8667264 Merge pull request 'feat: Ops: hourly release pulse GITEA_TOKEN authentication regression' (#39) from timmy/37-ops-hourly-release-pulse-gitea-token-authenticat into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-05 12:03:03 +00:00
timmy 9cdf9110fb fix: clarify stale release token recovery (#37)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 12:02:15 +00:00
timmy 360425d3ca Merge pull request 'feat: Documentation audit for stackchain-dashboard' (#38) from timmy/35-documentation-audit-for-stackchain-dashboard into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-05 11:03:17 +00:00
timmy 324e1cab4e docs: document service health checks
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 6s
2026-08-05 11:02:20 +00:00
timmy 16e54b63f2 Merge pull request 'feat: Improve stackchain-dashboard health and reliability' (#36) from timmy/34-improve-stackchain-dashboard-health-and-reliabil into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-05 10:03:46 +00:00
timmy 610b659fd4 feat: add Gitea readiness probe
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 10:02:54 +00:00
rockachopa a87e9d2db4 Merge pull request 'feat: Add realtime Gitea event stream widget' (#33) from timmy/31-add-realtime-gitea-event-stream-widget into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-05 09:04:14 +00:00
timmy 5a3f0128c4 feat: report Gitea event stream freshness
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 6s
2026-08-05 09:03:20 +00:00
rockachopa 1e17771840 Merge pull request 'feat: Fallback context endpoint timeout' (#32) from timmy/30-fallback-context-endpoint-timeout into main
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
Merge PR #32: bound context fallback latency
2026-08-05 08:03:24 +00:00
27 changed files with 630 additions and 73 deletions

No files matched your search

+5
View File
@@ -28,6 +28,11 @@ Gitea connection directly, request
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
it in a tracked configuration file.
For service monitoring, GET `/healthz` is a liveness check that confirms the
API process is running and does not contact Gitea. GET `/readyz` is the
readiness check: it validates the configured Gitea credentials and returns
HTTP 503 with an error when Gitea is unavailable or authentication fails.
Run the test suite with:
```bash
+10
View File
@@ -0,0 +1,10 @@
(function (root, factory) {
const filterCommands = factory();
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
if (root) root.filterCommands = filterCommands;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function filterCommands(commands, filter) {
const term = String(filter || '').toLowerCase();
return commands.filter(command => command.name.toLowerCase().includes(term));
};
});
+39 -42
View File
@@ -102,10 +102,20 @@ textarea { resize: vertical; min-height: 120px; }
<details class="panel" data-panel-key="gitea-events" open>
<summary><h2>Gitea event stream</h2></summary>
<div id="gitea-events" class="muted" aria-live="polite">Loading…</div>
<div id="gitea-events-status" class="small" aria-live="polite">Waiting for activity…</div>
</details>
<details class="panel" data-panel-key="widgets" open>
<summary><h2>Live Widgets</h2></summary>
<div id="widgets" class="stack"></div>
<div id="widgets" class="stack">
<div class="widget">
<h3>Live clock</h3>
<div class="small" id="widget-clock">—</div>
</div>
<div class="widget">
<h3>Repo mix</h3>
<div class="small" id="repo-mix">Loading…</div>
</div>
</div>
</details>
</section>
@@ -149,6 +159,8 @@ textarea { resize: vertical; min-height: 120px; }
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script>
(function(){
const qs = (s, el=document) => el.querySelector(s);
@@ -183,7 +195,7 @@ textarea { resize: vertical; min-height: 120px; }
async function load() {
setStatus('Loading…');
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
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;
@@ -245,19 +257,25 @@ textarea { resize: vertical; min-height: 120px; }
).join('') : '<div class="muted">No recent Gitea activity.</div>';
}
function setEventStreamStatus(message) {
qs('#gitea-events-status').textContent = message;
}
async function loadEventStream() {
setEventStreamStatus('Updating…');
try {
const res = await fetch('/api/v1/events', { headers: { Accept: 'application/json' } });
const res = await fetch('api/v1/events', { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
paintEventStream(await res.json());
setEventStreamStatus('Updated ' + fmt(new Date()));
} catch (e) {
qs('#gitea-events').innerHTML = '<div class="muted">Event stream unavailable.</div>';
setEventStreamStatus('Update failed · showing last activity');
}
}
async function tickWidgets() {
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
const 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) {
@@ -311,14 +329,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 */
@@ -330,11 +341,10 @@ 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(''); });
@@ -351,33 +361,20 @@ textarea { resize: vertical; min-height: 120px; }
tickWidgets();
/* Widgets */
const widgetOrder = ['countdown','repoAtAGlance'];
const widgetRegistry = {
countdown: () => ({ title: 'Live clock', html: '<div class="small" id="widget-clock">—</div>' }),
repoAtAGlance: async () => {
const el = qs('#repo-mix');
if (!el) return { title:'Repo mix', html:'<div class="small">Unavailable</div>' };
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
const data = await res.json();
const repos = data.repos || [];
const issues = (data.issues||[]).filter(i=>i.state==='open');
const prs = data.pull_requests || [];
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
} catch (e) { el.innerHTML = '<div class="muted">Widget unavailable.</div>'; }
return { title:'Repo mix', html:'<div class="small" id="repo-mix">Loading…</div>' };
},
};
const widgetsEl = qs('#widgets');
function paintWidgets() {
widgetsEl.innerHTML = '';
widgetOrder.forEach(async k => {
const w = await widgetRegistry[k]();
const node = document.createElement('div'); node.className='widget'; node.innerHTML = '<h3>' + escapeHtml(w.title) + '</h3>' + w.html;
widgetsEl.appendChild(node);
});
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>';
}
}
paintWidgets();
updateRepoMix();
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
+29
View File
@@ -0,0 +1,29 @@
(function (root, factory) {
const renderMarkdown = factory();
if (typeof module === 'object' && module.exports) module.exports = renderMarkdown;
root.renderMarkdown = renderMarkdown;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character]);
}
return function renderMarkdown(raw) {
return escapeHtml(raw)
.replace(/^#{1,6}\s.*$/gm, (line) => {
const level = line.match(/^(#{1,6})/)[1].length;
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
})
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br>');
};
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+79 -18
View File
@@ -1,4 +1,6 @@
import asyncio
import math
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -12,6 +14,9 @@ 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"
app.add_middleware(
CORSMiddleware,
@@ -21,6 +26,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router)
@@ -30,6 +36,38 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "stackchain-dashboard"}
@app.get("/readyz")
async def readiness():
"""Return readiness after verifying the configured Gitea connection."""
try:
user = await asyncio.wait_for(
current_user(), timeout=READINESS_TIMEOUT_SECONDS
)
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)
)
return JSONResponse(
{
"status": "not_ready",
"service": "stackchain-dashboard",
"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",
"service": "stackchain-dashboard",
"gitea_user": user["login"],
}
@app.get("/api/v1/context")
async def context() -> JSONResponse:
try:
@@ -54,34 +92,57 @@ async def context() -> JSONResponse:
{"panel": "gitea", "action": "set GITEA_URL + GITEA_TOKEN", "target": "/root/stackchain-dashboard/.env or systemd env", "priority": "high"},
],
"error": error_message,
})
user_model = User(id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name", ""), email=user_data.get("email", ""))
}, headers={
"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))
} if isinstance(e, TimeoutError) else None)
user_model = User(id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name") or "", email=user_data.get("email") or "")
repo_models = [
Repo(id=r["id"], name=r["name"], full_name=r["full_name"], description=r.get("description", ""), 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)
]
issue_models = [
Issue(id=i["id"], number=i["number"], title=i["title"], state=i["state"], labels=[l.get("name", "") for l in i.get("labels", [])], assignees=[a.get("login", "") for a in i.get("assignees", [])], url=i["html_url"])
for i in issues_data[:50]
Issue(id=i["id"], number=i["number"], title=i["title"], state=i["state"], labels=[l.get("name", "") for l in (i.get("labels") or [])], assignees=[a.get("login", "") for a in (i.get("assignees") or [])], url=i["html_url"])
for i in (issues_data or [])[:50]
if isinstance(i, dict)
]
pr_models = [
PullRequest(id=p["id"], number=p["number"], title=p["title"], state=p["state"], user=p.get("user", {}).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.get("user") or {}).get("login", ""), url=p["html_url"])
for p in (prs_data or [])[:50]
if isinstance(p, dict)
]
ctx = compute(user_model, repo_models, issue_models, pr_models)
return JSONResponse({
"user": ctx.user.dict(),
"repos": [r.dict() for r in ctx.repos],
"issues": [i.dict() for i in ctx.issues],
"pull_requests": [p.dict() for p in ctx.pull_requests],
"view": ctx.view,
"deltas": [d.dict() for d in ctx.deltas],
})
return JSONResponse(ctx.model_dump())
@app.get("/api/v1/events")
async def event_stream() -> list[dict]:
return await activity_events()
async def event_stream():
try:
return await asyncio.wait_for(
activity_events(), timeout=EVENT_STREAM_TIMEOUT_SECONDS
)
except TimeoutError:
return JSONResponse(
{
"error": (
"Gitea event stream request timed out after "
f"{EVENT_STREAM_TIMEOUT_SECONDS:g}s"
)
},
status_code=503,
headers={
"Retry-After": str(
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
)
},
)
except Exception:
return JSONResponse(
{"error": "Gitea event stream is temporarily unavailable"},
status_code=503,
headers={
"Retry-After": str(
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
)
},
)
+8 -8
View File
@@ -36,17 +36,17 @@ class PullRequest(BaseModel):
url: str
class SuggestionDelta(BaseModel):
panel: str
action: str
target: str = ""
priority: str = "medium"
class ViewContext(BaseModel):
user: User
repos: list[Repo] = []
issues: list[Issue] = []
pull_requests: list[PullRequest] = []
view: str = "dashboard"
deltas: list[dict] = []
class SuggestionDelta(BaseModel):
panel: str
action: str
target: str = ""
priority: str = "medium"
deltas: list[SuggestionDelta] = []
+2
View File
@@ -147,6 +147,8 @@ class GiteaClient:
return json.loads(raw) if raw else None
except error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
if exc.code == 401:
detail += " (authentication rejected; refresh the cron GITEA_TOKEN)"
raise RuntimeError(f"Gitea {method} {path} failed: HTTP {exc.code}: {detail}") from exc
@staticmethod
+4 -1
View File
@@ -1,9 +1,12 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
@router.get("/", response_class=HTMLResponse)
async def dashboard() -> str:
return open("frontend/index.html").read()
return DASHBOARD_FILE.read_text()
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
import re
from pathlib import Path
from urllib.parse import urljoin
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
def test_api_requests_resolve_inside_dashboard_subpath():
html = DASHBOARD_HTML.read_text()
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
assert api_paths
assert {
urljoin("https://forge.alexanderwhitestone.com/dashboard/", path)
for path in api_paths
} == {
"https://forge.alexanderwhitestone.com/dashboard/api/v1/context",
"https://forge.alexanderwhitestone.com/dashboard/api/v1/events",
}
+48
View File
@@ -0,0 +1,48 @@
import json
import subprocess
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin
FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js"
class ScriptSourceParser(HTMLParser):
def __init__(self):
super().__init__()
self.sources = []
def handle_starttag(self, tag, attrs):
if tag == "script":
source = dict(attrs).get("src")
if source:
self.sources.append(source)
def test_filtered_command_runs_the_matching_action():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
let action = '';
const commands = [
{{ name: 'Open whiteboard', run: () => {{ action = 'whiteboard'; }} }},
{{ name: 'Refresh now', run: () => {{ action = 'refresh'; }} }},
];
const matches = filterCommands(commands, 'refresh');
if (matches.length !== 1) throw new Error(`expected one match, got ${{matches.length}}`);
matches[0].run();
if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_command_script_resolves_inside_dashboard_subpath():
parser = ScriptSourceParser()
parser.feed((FRONTEND / "index.html").read_text())
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", command_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
+138 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import pytest
@@ -27,6 +28,142 @@ async def test_context_returns_fallback_when_gitea_exceeds_deadline(monkeypatch)
response = await main.context()
assert response.status_code == 200
assert response.headers["retry-after"] == "1"
assert b'"repos":[]' in response.body
assert b'"error":"Gitea context request timed out after 0.01s"' in response.body
assert cancelled.is_set()
assert cancelled.is_set()
@pytest.mark.anyio
async def test_context_normalizes_nullable_issue_collections(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def issue_collection():
return [{
"id": 50,
"number": 50,
"title": "Nullable metadata",
"state": "open",
"labels": None,
"assignees": None,
"html_url": "https://forge.example/issues/50",
}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", issue_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
issue = json.loads(response.body)["issues"][0]
assert issue["labels"] == []
assert issue["assignees"] == []
@pytest.mark.anyio
async def test_context_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_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_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"] == ""
+56
View File
@@ -1,3 +1,5 @@
import asyncio
import json
from pathlib import Path
import pytest
@@ -20,6 +22,21 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
assert "event.repo?.full_name" in html
def test_event_stream_reports_when_activity_was_refreshed():
html = DASHBOARD.read_text()
assert 'id="gitea-events-status"' in html
assert "setEventStreamStatus('Updating…')" in html
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
html = DASHBOARD.read_text()
assert "setEventStreamStatus('Update failed · showing last activity')" in html
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
@pytest.mark.anyio
async def test_event_stream_returns_recent_authenticated_gitea_activity(monkeypatch):
expected = [{"type": "create", "actor": {"login": "timmy"}}]
@@ -34,6 +51,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 = []
+65 -3
View File
@@ -1,6 +1,68 @@
from src.main import app, health
import asyncio
import json
import pytest
from src import main
def test_health_endpoint_reports_service_liveness_without_gitea_access():
assert any(getattr(route, "path", None) == "/healthz" for route in app.routes)
assert health() == {"status": "ok", "service": "stackchain-dashboard"}
assert any(getattr(route, "path", None) == "/healthz" for route in main.app.routes)
assert main.health() == {"status": "ok", "service": "stackchain-dashboard"}
@pytest.mark.anyio
async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
async def connected_user():
return {"login": "timmy"}
monkeypatch.setattr(main, "current_user", connected_user)
response = await main.readiness()
assert any(getattr(route, "path", None) == "/readyz" for route in main.app.routes)
assert response == {
"status": "ready",
"service": "stackchain-dashboard",
"gitea_user": "timmy",
}
@pytest.mark.anyio
async def test_readiness_endpoint_returns_503_when_gitea_is_unavailable(monkeypatch):
async def unavailable_user():
raise RuntimeError("connection refused")
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
@pytest.mark.anyio
async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monkeypatch):
cancelled = asyncio.Event()
async def hanging_user():
try:
await asyncio.sleep(0.05)
return {"login": "too-late"}
finally:
cancelled.set()
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
monkeypatch.setattr(main, "current_user", hanging_user)
response = await main.readiness()
assert response.status_code == 503
assert response.headers["retry-after"] == "1"
assert json.loads(response.body) == {
"status": "not_ready",
"service": "stackchain-dashboard",
"error": "Gitea readiness check timed out after 0.01s",
}
assert cancelled.is_set()
+52
View File
@@ -0,0 +1,52 @@
import json
import subprocess
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin
FRONTEND = Path(__file__).parent.parent / "frontend"
RENDERER = FRONTEND / "markdown.js"
class ScriptSourceParser(HTMLParser):
def __init__(self):
super().__init__()
self.sources = []
def handle_starttag(self, tag, attrs):
if tag == "script":
source = dict(attrs).get("src")
if source:
self.sources.append(source)
def test_markdown_renderer_escapes_raw_html_before_rendering_heading():
payload = "# <img src=x onerror=alert(document.domain)>"
script = (
f"const render = require({json.dumps(str(RENDERER))});"
f"process.stdout.write(render({json.dumps(payload)}));"
)
result = subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
)
assert result.stdout == (
"<h1>&lt;img src=x onerror=alert(document.domain)&gt;</h1>"
)
def test_markdown_script_resolves_inside_dashboard_subpath():
parser = ScriptSourceParser()
parser.feed((FRONTEND / "index.html").read_text())
markdown_source = next(
source for source in parser.sources if source.endswith("markdown.js")
)
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
+9
View File
@@ -23,3 +23,12 @@ def test_readme_documents_a_complete_local_quickstart():
assert "uvicorn src.main:app" in text
assert "http://127.0.0.1:8000/api/v1/context" in text
assert "uvicorn==" in REQUIREMENTS.read_text()
def test_readme_documents_liveness_and_gitea_readiness_checks():
text = README.read_text()
assert "`/healthz`" in text
assert "`/readyz`" in text
assert "does not contact Gitea" in text
assert "HTTP 503" in text
+25
View File
@@ -1,4 +1,6 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import pytest
@@ -6,6 +8,7 @@ import pytest
from src.release_engine import (
CommandResult,
EngineConfig,
GiteaClient,
Issue,
ReleaseEngine,
RunState,
@@ -15,6 +18,28 @@ from src.release_engine import (
)
def test_stale_token_error_explains_how_to_restore_cron_authentication():
class UnauthorizedHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(401)
self.end_headers()
self.wfile.write(b'{"message":"user does not exist [uid: 0, name: ]"}')
def log_message(self, format, *args):
pass
server = HTTPServer(("127.0.0.1", 0), UnauthorizedHandler)
thread = threading.Thread(target=server.handle_request)
thread.start()
try:
client = GiteaClient(f"http://127.0.0.1:{server.server_port}", "stale-token")
with pytest.raises(RuntimeError, match="refresh the cron GITEA_TOKEN"):
client.list_open_issues("stackchain/stackchain-dashboard")
finally:
thread.join(timeout=2)
server.server_close()
class FakeClient:
def __init__(self, issues, verified_assignee="timmy", existing_pr=None):
self.issues = issues
+18
View File
@@ -0,0 +1,18 @@
import subprocess
def test_python_bytecode_is_not_tracked():
tracked = subprocess.run(
["git", "ls-files"],
check=True,
capture_output=True,
text=True,
).stdout.splitlines()
bytecode = [
path
for path in tracked
if "__pycache__" in path.split("/") or path.endswith((".pyc", ".pyo"))
]
assert bytecode == []
+12
View File
@@ -0,0 +1,12 @@
import pytest
from src.views import dashboard
@pytest.mark.anyio
async def test_dashboard_renders_outside_repository_working_directory(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
html = await dashboard()
assert "Stackchain Dashboard" in html
+11
View File
@@ -0,0 +1,11 @@
import pytest
from src.views import dashboard
@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