Compare commits

...
Author SHA1 Message Date
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
timmy 286680c053 fix: bound context fallback latency (#30)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 4s
2026-08-05 08:02:39 +00:00
rockachopa 426c0645f9 Merge pull request 'feat: Add realtime Gitea event stream widget' (#29) from timmy/27-add-realtime-gitea-event-stream-widget into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-05 04:05:11 +00:00
timmy 9df3815417 feat: add realtime Gitea event stream widget
CI / lint (pull_request) Successful in 10s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 04:04:40 +00:00
rockachopa 0fc4b2684e Merge pull request 'feat: Improve stackchain-dashboard health and reliability' (#28) from timmy/26-improve-stackchain-dashboard-health-and-reliabil into main
CI / lint (push) Successful in 10s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 6s
2026-08-05 03:03:52 +00:00
timmy 20a6820d9a feat: add independent dashboard health endpoint (#26)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s
2026-08-05 03:02:51 +00:00
timmy fef8480e5f Merge pull request 'feat: Documentation audit for stackchain-dashboard' (#25) from timmy/24-documentation-audit-for-stackchain-dashboard into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-04 21:03:49 +00:00
timmy 19377ad487 docs: add tested local quickstart for issue 24
CI / lint (pull_request) Successful in 8s
CI / build-frontend (pull_request) Successful in 5s
2026-08-04 21:02:56 +00:00
rockachopa bf935b0ad4 Merge pull request 'feat: Documentation audit for stackchain-dashboard' (#23) from timmy/19-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 5s
2026-08-04 15:03:06 +00:00
timmy bcf1a5d5a0 docs: audit release worker instructions (#19)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 6s
2026-08-04 15:02:15 +00:00
rockachopa 1dd0e6e133 Merge pull request 'feat: Collapsible panel state persistence' (#21) from timmy/9-collapsible-panel-state-persistence into main
CI / lint (push) Successful in 9s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 6s
2026-08-04 15:00:18 +00:00
timmy f2a14467b2 fix: use Gitea-compatible artifact upload
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 25s
2026-08-04 14:59:22 +00:00
timmy efcd1fb36e fix: declare CI dependencies
CI / lint (pull_request) Successful in 18s
CI / build-frontend (pull_request) Failing after 23s
2026-08-04 14:57:43 +00:00
timmy 6bfc97dcdf feat: persist collapsible dashboard panels
CI / build-frontend (pull_request) Has been skipped
CI / lint (pull_request) Failing after 7s
2026-08-04 14:53:06 +00:00
timmy 8a406b6254 chore: verify public-url actions runner
CI / lint (push) Failing after 19s
CI / build-frontend (push) Has been skipped
Release / release-candidate (push) Successful in 6s
2026-08-04 14:42:31 +00:00
timmy a2ffa9339d chore: trigger verified release workflow
CI / lint (push) Failing after 42s
CI / build-frontend (push) Has been skipped
Release / release-candidate (push) Failing after 31s
2026-08-04 14:37:45 +00:00
rockachopa ce376aaa6e Merge pull request 'feat: Fix release workflow: merged PR produced no tag or release' (#17) from timmy/16-fix-release-workflow-merged-pr-produced-no-tag-o into main
CI / lint (push) Waiting to run
CI / build-frontend (push) Blocked by required conditions
Release / release-candidate (push) Waiting to run
2026-08-04 14:33:22 +00:00
timmy 6da9847750 fix: create releases through Gitea API
CI / lint (pull_request) Failing after 39s
CI / build-frontend (pull_request) Has been skipped
2026-08-04 14:32:54 +00:00
16 changed files with 503 additions and 63 deletions

No files matched your search

+1 -1
View File
@@ -25,5 +25,5 @@ jobs:
- name: Pack frontend
run: tar -czf frontend.tar.gz frontend
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with: { name: frontend, path: frontend.tar.gz }
+27 -22
View File
@@ -6,30 +6,35 @@ on:
workflow_dispatch:
jobs:
tag-release:
release-candidate:
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Auto-tag
run: |
set -e
TAG="0.0.${{ github.run_number }}"
git tag "$TAG" || true
git push origin "$TAG" || true
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
with:
fetch-depth: 0
draft-rc:
runs-on: ubuntu-latest
needs: tag-release
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Draft release
run: |
set -e
TAG="${{ needs.tag-release.outputs.tag }}"
gh release create "$TAG" --draft --title "RC $TAG" --notes "Automated release candidate for $TAG." || true
- name: Create Gitea tag and release candidate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="v0.1.0-rc.${{ github.run_number }}"
TARGET="${{ github.sha }}"
TAG_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags"
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
printf '{"tag_name":"%s","target":"%s","message":"Automated release candidate %s"}\n' \
"$TAG" "$TARGET" "$TAG" > /tmp/tag.json
curl --fail-with-body -sS -X POST "$TAG_URL" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/tag.json
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"Automated release candidate for commit %s.","draft":false,"prerelease":true}\n' \
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/release.json
+50 -5
View File
@@ -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`.
@@ -20,16 +57,24 @@ python3 -m src.release_engine \
--dry-run
```
Execute one ticket:
Execute one ticket after implementing and committing its change directly on
the deterministic branch printed by `--dry-run`:
```bash
export RELEASE_AGENT_COMMAND='codex exec "Implement Gitea issue #{issue_number}: {issue_title}. Follow the issue body and run tests."'
export GITEA_TOKEN='<scoped-token>'
export RELEASE_AGENT_COMMAND=true
python3 -m src.release_engine \
--repo stackchain/stackchain-dashboard \
--agent timmy \
--issue 19 \
--test-command 'python3 -m pytest tests/ -q'
```
Required secret: `GITEA_TOKEN`. Durable state defaults to
`.release-engine/state.json`. Full behavior and safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
`GITEA_TOKEN` needs issue and repository write scopes. The engine verifies the
claim, reruns the test command, pushes the branch, and opens a PR containing
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
do not run without `--issue` when processing a preselected ticket.
Durable state defaults to `.release-engine/state.json`. Full behavior and
safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
+88 -28
View File
@@ -14,6 +14,9 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
button:hover { filter: brightness(1.15); }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
.panel > summary h2 { display: inline-block; margin-left: 4px; }
.panel[open] > summary { margin-bottom: 8px; }
aside, section { position: relative; z-index: 1; }
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
@@ -49,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); } }
@@ -68,7 +73,8 @@ textarea { resize: vertical; min-height: 120px; }
<main>
<aside class="sidebar">
<div class="panel stack">
<details class="panel stack" data-panel-key="context" open>
<summary><h2>Context &amp; view</h2></summary>
<div>
<h2>Context</h2>
<div class="muted" id="context">Loading…</div>
@@ -77,41 +83,46 @@ textarea { resize: vertical; min-height: 120px; }
<h2>View</h2>
<div class="muted" id="view-hint">—</div>
</div>
</div>
<div class="panel stack">
<h2>AI Stream</h2>
</details>
<details class="panel stack" data-panel-key="ai-stream" open>
<summary><h2>AI Stream</h2></summary>
<div id="ai" class="muted">Loading…</div>
</div>
</details>
</aside>
<section class="stack" id="work">
<div class="panel" id="issues">
<h2>Issues</h2>
<div class="muted">Loading…</div>
</div>
<div class="panel" id="prs">
<h2>Pull Requests</h2>
<div class="muted">Loading…</div>
</div>
<div class="panel">
<h2>Live Widgets</h2>
<details class="panel" id="issues" data-panel-key="issues" open>
<summary><h2>Issues</h2></summary>
<div id="issues-content" class="muted">Loading…</div>
</details>
<details class="panel" id="prs" data-panel-key="pull-requests" open>
<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>
</div>
</details>
</section>
<aside class="sidebar">
<div class="panel stack">
<h2>Layout</h2>
<details class="panel stack" data-panel-key="layout" open>
<summary><h2>Layout</h2></summary>
<div class="muted" id="layout-hint">—</div>
</div>
<div class="panel stack">
<h2>Markdown</h2>
</details>
<details class="panel stack" data-panel-key="markdown" open>
<summary><h2>Markdown</h2></summary>
<textarea id="md-input" placeholder="# Write markdown here...
- bullet
- **bold**
- `code`"></textarea>
<div id="md-preview" style="margin-top:8px;"></div>
</div>
</details>
</aside>
</main>
@@ -143,6 +154,27 @@ textarea { resize: vertical; min-height: 120px; }
(function(){
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
const PANEL_STATE_KEY = "stackchain.panel-state.v1";
const panels = Array.from(document.querySelectorAll('details[data-panel-key]'));
let savedState = {};
try {
savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}');
} catch (e) {
savedState = {};
}
panels.forEach((panel) => {
if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) {
panel.open = savedState[panel.dataset.panelKey];
}
panel.addEventListener('toggle', () => {
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
try {
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Could not persist panel state', e);
}
});
});
let liveMode = true;
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
@@ -162,14 +194,14 @@ textarea { resize: vertical; min-height: 120px; }
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
const issuesBox = qs('#issues');
const issuesBox = qs('#issues-content');
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
issuesBox.innerHTML = '<h2>Issues</h2>' + (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
const prsBox = qs('#prs');
const prsBox = qs('#prs-content');
const openPrs = (data.pull_requests || []).slice(0, 12);
prsBox.innerHTML = '<h2>Pull Requests</h2>' + (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
paintDeltas(data.deltas || []);
@@ -186,11 +218,11 @@ textarea { resize: vertical; min-height: 120px; }
{ number: 8, title: 'Live UI implementation', state: 'open', labels: ['frontend'], url: '#8' },
{ number: 37, title: 'Docker build and push', state: 'open', labels: ['devops'], url: '#37' },
];
qs('#issues').innerHTML = '<h2>Issues</h2>' + mockIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '">#' + i.number + ' ' + escapeHtml(i.title) + '</a><div class="muted">' + (i.labels||[]).map(l=>'<span class="pill">'+escapeHtml(String(l))+'</span>').join(' ') + '</div></div>').join('');
qs('#issues-content').innerHTML = mockIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '">#' + i.number + ' ' + escapeHtml(i.title) + '</a><div class="muted">' + (i.labels||[]).map(l=>'<span class="pill">'+escapeHtml(String(l))+'</span>').join(' ') + '</div></div>').join('');
const mockPrs = [
{ number: 4, title: 'Agent UI delta binding', state: 'open', user: 'turdbot', url: '#4' },
];
qs('#prs').innerHTML = '<h2>Pull Requests</h2>' + mockPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '">#' + p.number + ' ' + escapeHtml(p.title) + '</a><div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user||'')) + '</div></div>').join('');
qs('#prs-content').innerHTML = mockPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '">#' + p.number + ' ' + escapeHtml(p.title) + '</a><div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user||'')) + '</div></div>').join('');
paintDeltas([
{panel:'triage', action:'open', target:'#1', priority:'high'},
{panel:'review', action:'review', target:'#4', priority:'medium'},
@@ -204,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' } });
@@ -293,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();
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.133.1
httpx==0.28.1
pydantic==2.13.4
pytest==9.1.1
uvicorn==0.41.0
+14
View File
@@ -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
View File
@@ -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()
+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
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
WORKFLOW = Path(".gitea/workflows/ci.yml")
def test_ci_uses_gitea_compatible_artifact_action():
text = WORKFLOW.read_text()
assert "actions/upload-artifact@v4" not in text
assert "actions/upload-artifact@v3" in text
def test_ci_installs_declared_requirements_before_tests():
text = WORKFLOW.read_text()
install = text.index("pip install -r requirements.txt")
tests = text.index("python3 -m pytest tests/ -q")
assert install < tests
+33
View File
@@ -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()
+72
View File
@@ -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"]
+39
View File
@@ -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
+22
View File
@@ -0,0 +1,22 @@
from pathlib import Path
DASHBOARD = Path("frontend/index.html")
def test_dashboard_panels_are_independently_collapsible():
html = DASHBOARD.read_text()
for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"):
assert f'<details class="panel' in html
assert f'data-panel-key="{panel_key}"' in html
def test_panel_open_state_is_restored_and_saved_in_local_storage():
html = DASHBOARD.read_text()
assert 'const PANEL_STATE_KEY = "stackchain.panel-state.v1"' in html
assert "localStorage.getItem(PANEL_STATE_KEY)" in html
assert "panel.open = savedState[panel.dataset.panelKey]" in html
assert "localStorage.setItem(PANEL_STATE_KEY" in html
assert "panel.addEventListener('toggle'" in html
+34
View File
@@ -0,0 +1,34 @@
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():
text = README.read_text()
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
+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
+24
View File
@@ -0,0 +1,24 @@
from pathlib import Path
WORKFLOW = Path(".gitea/workflows/release.yml")
def test_release_workflow_uses_gitea_api_not_github_cli():
text = WORKFLOW.read_text()
assert "gh release" not in text
assert "/api/v1/repos/${{ github.repository }}/tags" in text
assert "/api/v1/repos/${{ github.repository }}/releases" in text
def test_release_workflow_targets_merge_commit_and_marks_rc():
text = WORKFLOW.read_text()
assert 'TARGET="${{ github.sha }}"' in text
assert '"prerelease":true' in text
assert '"draft":false' in text
def test_release_workflow_fails_on_api_error():
text = WORKFLOW.read_text()
assert "curl --fail-with-body" in text
assert "|| true" not in text