diff --git a/frontend/index.html b/frontend/index.html
index cae60a2..f5845e3 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -52,6 +52,8 @@ textarea { resize: vertical; min-height: 120px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
+.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
+.event:last-child { border-bottom: 0; }
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@@ -97,6 +99,10 @@ textarea { resize: vertical; min-height: 120px; }
Pull Requests
Loading…
+
+ Gitea event stream
+ Loading…
+
Live Widgets
@@ -229,6 +235,26 @@ textarea { resize: vertical; min-height: 120px; }
el.innerHTML = deltas.length ? deltas.map(d => '' + escapeHtml(d.priority) + ' ' + escapeHtml(d.action) + ' ' + escapeHtml(d.target || '') + '
' + escapeHtml(d.panel) + '
').join('') : 'No suggestions yet.
';
}
+ function paintEventStream(events) {
+ const el = qs('#gitea-events');
+ el.innerHTML = events.length ? events.slice(0, 12).map(event =>
+ '' + escapeHtml(event.actor?.login || 'Gitea') + ' ' +
+ escapeHtml(event.type || 'activity') +
+ '
' + escapeHtml(event.repo?.full_name || '') +
+ (event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '
'
+ ).join('') : 'No recent Gitea activity.
';
+ }
+
+ async function loadEventStream() {
+ try {
+ const res = await fetch('/api/v1/events', { headers: { Accept: 'application/json' } });
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ paintEventStream(await res.json());
+ } catch (e) {
+ qs('#gitea-events').innerHTML = 'Event stream unavailable.
';
+ }
+ }
+
async function tickWidgets() {
try {
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
@@ -318,7 +344,9 @@ textarea { resize: vertical; min-height: 120px; }
qs('#refresh').addEventListener('click', load);
load();
+ loadEventStream();
setInterval(load, 8000);
+ setInterval(loadEventStream, 5000);
setInterval(tickWidgets, 2000);
tickWidgets();
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index c45bdc7..6d04c8e 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -35,3 +35,17 @@ async def issues() -> list[dict]:
async def pull_requests() -> list[dict]:
return await fetch("user/pulls?limit=50")
+
+
+async def activity_events() -> list[dict]:
+ user = await current_user()
+ events = await fetch(f"users/{user['login']}/activities/feeds?limit=20")
+ return [
+ {
+ "type": event.get("op_type", "activity"),
+ "actor": event.get("act_user") or {},
+ "repo": event.get("repo") or {},
+ "created_at": event.get("created", ""),
+ }
+ for event in events
+ ]
diff --git a/src/main.py b/src/main.py
index 6da7ab4..c07ffb2 100644
--- a/src/main.py
+++ b/src/main.py
@@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
-from src.gitea_proxy import current_user, repos, issues, pull_requests
+from src.gitea_proxy import activity_events, current_user, repos, issues, pull_requests
from src.models import User, Repo, Issue, PullRequest
from src.suggestion_engine import compute
from src.views import router as frontend_router
@@ -72,3 +72,8 @@ async def context() -> JSONResponse:
"view": ctx.view,
"deltas": [d.dict() for d in ctx.deltas],
})
+
+
+@app.get("/api/v1/events")
+async def event_stream() -> list[dict]:
+ return await activity_events()
diff --git a/tests/test_event_stream.py b/tests/test_event_stream.py
new file mode 100644
index 0000000..a76ae13
--- /dev/null
+++ b/tests/test_event_stream.py
@@ -0,0 +1,64 @@
+from pathlib import Path
+
+import pytest
+
+from src import main
+from src import gitea_proxy
+
+
+DASHBOARD = Path("frontend/index.html")
+
+
+def test_dashboard_has_realtime_gitea_event_stream_widget():
+ html = DASHBOARD.read_text()
+
+ assert "Gitea event stream" in html
+ assert "id=\"gitea-events\"" in html
+ assert "function paintEventStream" in html
+ assert "setInterval(loadEventStream, 5000)" in html
+ assert "event.actor?.login" in html
+ assert "event.repo?.full_name" in html
+
+
+@pytest.mark.anyio
+async def test_event_stream_returns_recent_authenticated_gitea_activity(monkeypatch):
+ expected = [{"type": "create", "actor": {"login": "timmy"}}]
+
+ async def fake_activity_events():
+ return expected
+
+ monkeypatch.setattr(main, "activity_events", fake_activity_events)
+
+ response = await main.event_stream()
+
+ assert response == expected
+
+
+@pytest.mark.anyio
+async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
+ paths = []
+
+ async def fake_current_user():
+ return {"login": "timmy"}
+
+ async def fake_fetch(path):
+ paths.append(path)
+ return [{
+ "op_type": "push",
+ "act_user": {"login": "timmy"},
+ "repo": {"full_name": "stackchain/stackchain-dashboard"},
+ "created": "2026-08-05T03:00:00Z",
+ }]
+
+ 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-05T03:00:00Z",
+ }]
+ assert paths == ["users/timmy/activities/feeds?limit=20"]