Compare commits

...
Author SHA1 Message Date
timmy 6e24d7db17 fix: normalize null activity feed payloads (#93)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:46:24 +00:00
rockachopa 43a2804562 Merge pull request 'Return fallback context for incomplete current-user payload' (#92) from timmy/91-return-fallback-incomplete-current-user into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 09:18:02 +00:00
timmy 42b659e807 fix: fallback on incomplete current user (#91)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:17:16 +00:00
rockachopa 240d962ec3 Merge pull request 'Skip malformed Gitea activity feed entries' (#90) from timmy/89-skip-malformed-activity-feed-entries into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 5s
2026-08-06 09:01:36 +00:00
timmy 2aba489036 fix: skip malformed activity feed entries (#89)
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 09:00:45 +00:00
rockachopa 10181aa019 Merge pull request 'Normalize malformed Gitea pull request authors' (#88) from timmy/87-normalize-malformed-gitea-pr-authors into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-06 08:18:28 +00:00
timmy 2d492d9d2e fix: normalize malformed pull request authors (#87)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 08:17:42 +00:00
timmy 1fe16c1342 Merge pull request 'Skip malformed nested Gitea issue metadata entries' (#86) from timmy/85-skip-malformed-nested-issue-metadata into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 07:48:49 +00:00
timmy 68c2048724 fix: skip malformed nested issue metadata (#85)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 07:48:04 +00:00
timmy fcdca7a37c Skip malformed Gitea pull request entries (#84)
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #83
2026-08-06 07:18:25 +00:00
timmy 77404cfd03 fix: skip malformed pull request entries (#83)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 07:17:40 +00:00
timmy 49ce2d9945 Merge pull request 'Skip malformed Gitea issue entries' (#82) from timmy/81-skip-malformed-gitea-issue-entries into main
CI / lint (push) Successful in 8s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
Closes #81
2026-08-06 06:48:38 +00:00
timmy 5f86b58b88 fix: skip malformed issue entries (#81)
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 06:47:47 +00:00
rockachopa 888c03e4c1 Merge pull request 'Skip malformed Gitea repository entries' (#80) from timmy/79-skip-malformed-repository-entries into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 06:19:24 +00:00
timmy bce46843b3 fix: skip malformed repository entries (#79)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-06 06:18:22 +00:00
rockachopa 89b14872e6 Merge pull request 'Return not-ready for invalid Gitea current-user payload' (#78) from timmy/77-return-not-ready-invalid-current-user into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 05:48:27 +00:00
timmy 090a059c17 fix: reject invalid readiness user payload (#77)
CI / lint (pull_request) Successful in 7s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 05:47:38 +00:00
timmy b2f8eaf89a Merge pull request 'Handle null Gitea current-user payload' (#76) from timmy/75-handle-null-gitea-current-user-payload into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 3s
CI / build-frontend (push) Successful in 4s
2026-08-06 05:24:39 +00:00
timmy 8001933692 fix: handle null Gitea current-user payload (#75)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-06 05:23:58 +00:00
timmy d9ad68b72b Merge pull request 'Normalize nullable Gitea user profile fields' (#74) from timmy/73-normalize-nullable-user-profile-fields into main
CI / lint (push) Successful in 7s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-06 05:03:45 +00:00
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
10 changed files with 585 additions and 21 deletions

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
(function (root, factory) {
const filterCommands = factory();
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
if (root) root.filterCommands = filterCommands;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
return function filterCommands(commands, filter) {
const term = String(filter || '').toLowerCase();
return commands.filter(command => command.name.toLowerCase().includes(term));
};
});
+8 -8
View File
@@ -159,7 +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/markdown.js"></script>
<script src="static/commands.js"></script>
<script>
(function(){
const qs = (s, el=document) => el.querySelector(s);
@@ -194,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;
@@ -263,7 +264,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()));
@@ -274,7 +275,7 @@ textarea { resize: vertical; min-height: 120px; }
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) {
@@ -340,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(''); });
@@ -364,7 +364,7 @@ textarea { resize: vertical; min-height: 120px; }
async function updateRepoMix() {
const el = qs('#repo-mix');
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();
const repos = data.repos || [];
const issues = (data.issues||[]).filter(i=>i.state==='open');
+2 -1
View File
@@ -47,5 +47,6 @@ async def activity_events() -> list[dict]:
"repo": event.get("repo") or {},
"created_at": event.get("created", ""),
}
for event in events
for event in (events or [])
if isinstance(event, dict)
]
+93 -10
View File
@@ -14,6 +14,8 @@ 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(
@@ -38,15 +40,28 @@ 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 ValueError("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)
)
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",
@@ -62,6 +77,10 @@ async def context() -> JSONResponse:
asyncio.gather(current_user(), repos(), issues(), pull_requests()),
timeout=CONTEXT_TIMEOUT_SECONDS,
)
if not isinstance(user_data, dict):
raise ValueError("Gitea current-user response was not an object")
if not all(field in user_data for field in ("id", "login")):
raise ValueError("Gitea current-user response did not include id and login")
except Exception as e:
error_message = (
f"Gitea context request timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"
@@ -82,26 +101,90 @@ 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") or "", url=r["html_url"], updated_at=r.get("updated_at", ""))
for r in repo_data[:50]
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)
],
url=i["html_url"],
)
for i in (issues_data or [])[:50]
if isinstance(i, dict)
and all(
field in i
for field in ("id", "number", "title", "state", "html_url")
)
]
pr_models = [
PullRequest(id=p["id"], number=p["number"], title=p["title"], state=p["state"], user=(p.get("user") or {}).get("login", ""), url=p["html_url"])
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 ""
),
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))
)
},
)
+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"
+245 -1
View File
@@ -65,6 +65,37 @@ async def test_context_normalizes_nullable_issue_collections(monkeypatch):
assert issue["assignees"] == []
@pytest.mark.anyio
async def test_context_skips_malformed_nested_issue_metadata(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty_collection():
return []
async def issue_collection():
return [{
"id": 85,
"number": 85,
"title": "Malformed nested metadata",
"state": "open",
"labels": [None, {"name": "bug"}, "invalid"],
"assignees": [None, {"login": "timmy"}, "invalid"],
"html_url": "https://forge.example/issues/85",
}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", issue_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
response = await main.context()
issue = json.loads(response.body)["issues"][0]
assert issue["labels"] == ["bug"]
assert issue["assignees"] == ["timmy"]
@pytest.mark.anyio
async def test_context_normalizes_nullable_repository_description(monkeypatch):
async def user():
@@ -119,4 +150,217 @@ async def test_context_normalizes_nullable_pull_request_author(monkeypatch):
response = await main.context()
pull_request = json.loads(response.body)["pull_requests"][0]
assert pull_request["user"] == ""
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_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"
]
+85
View File
@@ -1,3 +1,5 @@
import asyncio
import json
from pathlib import Path
import pytest
@@ -49,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 = []
@@ -77,3 +118,47 @@ 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() == []
+46
View File
@@ -1,3 +1,6 @@
import asyncio
import json
import pytest
from src import main
@@ -37,3 +40,46 @@ async def test_readiness_endpoint_returns_503_when_gitea_is_unavailable(monkeypa
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_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()
+28 -1
View File
@@ -1,9 +1,24 @@
import json
import subprocess
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin
RENDERER = Path(__file__).parent.parent / "frontend" / "markdown.js"
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():
@@ -23,3 +38,15 @@ def test_markdown_renderer_escapes_raw_html_before_rendering_heading():
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"