Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965b24a183 | ||
|
|
9ea8667264 | ||
|
|
9cdf9110fb | ||
|
|
360425d3ca | ||
|
|
324e1cab4e | ||
|
|
16e54b63f2 | ||
|
|
610b659fd4 | ||
|
|
a87e9d2db4 | ||
|
|
5a3f0128c4 | ||
|
|
1e17771840 | ||
|
|
286680c053 | ||
|
|
426c0645f9 | ||
|
|
9df3815417 | ||
|
|
0fc4b2684e | ||
|
|
20a6820d9a | ||
|
|
fef8480e5f | ||
|
|
19377ad487 |
No files matched your search
@@ -2,6 +2,43 @@
|
||||
|
||||
Live AI-driven adaptive UI for the Stackchain AI Lab Gitea experience.
|
||||
|
||||
## Local quickstart
|
||||
|
||||
Python 3.11 or newer is recommended. Create an isolated environment and install
|
||||
the pinned dependencies:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||
read-scoped access token, then start the API and bundled frontend:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://forge.example.com'
|
||||
export GITEA_TOKEN='<read-scoped-token>'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
|
||||
Gitea connection directly, request
|
||||
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
|
||||
`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
|
||||
python3 -m pytest tests/ -q
|
||||
```
|
||||
|
||||
## Autonomous release worker
|
||||
|
||||
The deterministic issue-to-release engine lives at `src/release_engine.py`.
|
||||
|
||||
@@ -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,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@@ -97,6 +99,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<summary><h2>Pull Requests</h2></summary>
|
||||
<div id="prs-content" class="muted">Loading…</div>
|
||||
</details>
|
||||
<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>
|
||||
@@ -229,6 +236,32 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
||||
}
|
||||
|
||||
function paintEventStream(events) {
|
||||
const el = qs('#gitea-events');
|
||||
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
|
||||
'<div class="event"><strong>' + escapeHtml(event.actor?.login || 'Gitea') + '</strong> ' +
|
||||
escapeHtml(event.type || 'activity') +
|
||||
'<div class="small">' + escapeHtml(event.repo?.full_name || '') +
|
||||
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '</div></div>'
|
||||
).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' } });
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
async function tickWidgets() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
@@ -318,7 +351,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
load();
|
||||
loadEventStream();
|
||||
setInterval(load, 8000);
|
||||
setInterval(loadEventStream, 5000);
|
||||
setInterval(tickWidgets, 2000);
|
||||
tickWidgets();
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
pytest==9.1.1
|
||||
uvicorn==0.41.0
|
||||
@@ -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
|
||||
]
|
||||
+50
-7
@@ -1,14 +1,18 @@
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
from fastapi import FastAPI
|
||||
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
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard")
|
||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -21,14 +25,46 @@ app.add_middleware(
|
||||
app.include_router(frontend_router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def health() -> dict[str, str]:
|
||||
"""Return process liveness without depending on Gitea."""
|
||||
return {"status": "ok", "service": "stackchain-dashboard"}
|
||||
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readiness():
|
||||
"""Return readiness after verifying the configured Gitea connection."""
|
||||
try:
|
||||
user = await current_user()
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": str(exc),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
return {
|
||||
"status": "ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"gitea_user": user["login"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/context")
|
||||
async def context() -> JSONResponse:
|
||||
try:
|
||||
user_data = await current_user()
|
||||
repo_data = await repos()
|
||||
issues_data = await issues()
|
||||
prs_data = await pull_requests()
|
||||
user_data, repo_data, issues_data, prs_data = await asyncio.wait_for(
|
||||
asyncio.gather(current_user(), repos(), issues(), pull_requests()),
|
||||
timeout=CONTEXT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = (
|
||||
f"Gitea context request timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"
|
||||
if isinstance(e, TimeoutError)
|
||||
else str(e)
|
||||
)
|
||||
return JSONResponse({
|
||||
"user": {"id": None, "login": "timmy", "full_name": "Timmy Jr", "email": ""},
|
||||
"repos": [],
|
||||
@@ -39,8 +75,10 @@ async def context() -> JSONResponse:
|
||||
{"panel": "auth", "action": "connect", "target": "stackchain-dashboard backend", "priority": "high"},
|
||||
{"panel": "gitea", "action": "set GITEA_URL + GITEA_TOKEN", "target": "/root/stackchain-dashboard/.env or systemd env", "priority": "high"},
|
||||
],
|
||||
"error": str(e),
|
||||
})
|
||||
"error": error_message,
|
||||
}, 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", ""))
|
||||
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", ""))
|
||||
@@ -66,3 +104,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()
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_when_gitea_exceeds_deadline(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hanging_current_user():
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "CONTEXT_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main, "current_user", hanging_current_user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,72 @@
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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"]
|
||||
@@ -0,0 +1,39 @@
|
||||
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 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
|
||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
README = Path(__file__).parents[1] / "README.md"
|
||||
REQUIREMENTS = Path(__file__).parents[1] / "requirements.txt"
|
||||
|
||||
|
||||
def test_release_worker_instructions_use_direct_agent_execution():
|
||||
@@ -10,3 +11,24 @@ def test_release_worker_instructions_use_direct_agent_execution():
|
||||
assert "codex" not in text.lower()
|
||||
assert "RELEASE_AGENT_COMMAND=true" in text
|
||||
assert "--issue 19" in text
|
||||
|
||||
|
||||
def test_readme_documents_a_complete_local_quickstart():
|
||||
text = README.read_text()
|
||||
|
||||
assert "python3 -m venv .venv" in text
|
||||
assert "pip install -r requirements.txt" in text
|
||||
assert "GITEA_URL" in text
|
||||
assert "GITEA_TOKEN" in text
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user