generated from coulomb/repo-seed
feat(CUST-WP-0014): repo sync automation & Gitea inventory
- Migration e2f3a4b5c6d7: add last_state_synced_at to managed_repos
- consistency_check.py: PATCH last_state_synced_at after fix run;
fix ~ treated as non-empty state_hub_task_id (C-03 vs C-11);
fix _inject_task_id_into_block skipping injection when field exists
with null value
- install_hooks.sh: idempotent post-commit hook installer for all
registered repos (make install-hooks REPO= / install-hooks-all)
- gitea_inventory.py: compare coulomb Gitea org against state-hub
registered repos — registered / unregistered / hub-only sections
- infra/README.md: document systemd user timer + crontab fallback
- systemd user timer: custodian-sync.{service,timer} runs
fix-consistency-all every 15 min (enabled)
- dashboard/src/repo-sync.md: Repo Sync Health page — sync age table,
unregistered Gitea repos, hub-only repos
- api/routers/repos.py: GET /repos/{slug}/dispatch endpoint returning
active goal, pending tasks per workstream, human interventions
- mcp_server/server.py: get_repo_dispatch() MCP tool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
dashboard/src/data/gitea-inventory.json.py
Normal file
33
dashboard/src/data/gitea-inventory.json.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Observable data loader: runs gitea_inventory.py and returns JSON output."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "scripts")
|
||||
SCRIPTS_DIR = os.path.normpath(SCRIPTS_DIR)
|
||||
PYTHON = os.path.join(os.path.dirname(sys.executable), "python")
|
||||
if not os.path.exists(PYTHON):
|
||||
PYTHON = sys.executable
|
||||
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[PYTHON, os.path.join(SCRIPTS_DIR, "gitea_inventory.py"), "--json",
|
||||
"--api-base", API_BASE],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
print(result.stdout)
|
||||
else:
|
||||
print(json.dumps({
|
||||
"error": result.stderr or "empty output",
|
||||
"registered": [], "unregistered": [], "hub_only": [],
|
||||
}))
|
||||
except Exception as exc:
|
||||
print(json.dumps({
|
||||
"error": str(exc),
|
||||
"registered": [], "unregistered": [], "hub_only": [],
|
||||
}))
|
||||
159
dashboard/src/repo-sync.md
Normal file
159
dashboard/src/repo-sync.md
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
title: Repo Sync Health
|
||||
---
|
||||
|
||||
# Repo Sync Health
|
||||
|
||||
```js
|
||||
const repoData = await FileAttachment("data/repos.json").json();
|
||||
const inventory = await FileAttachment("data/gitea-inventory.json").json();
|
||||
|
||||
const repos = Array.isArray(repoData) ? repoData : (repoData.repos ?? []);
|
||||
```
|
||||
|
||||
```js
|
||||
// Helpers
|
||||
function ageMs(ts) {
|
||||
if (!ts) return Infinity;
|
||||
return Date.now() - new Date(ts).getTime();
|
||||
}
|
||||
|
||||
function fmtAge(ts) {
|
||||
if (!ts) return "never";
|
||||
const ms = ageMs(ts);
|
||||
const m = Math.floor(ms / 60000);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function syncColor(ts) {
|
||||
if (!ts) return "var(--theme-red)";
|
||||
const h = ageMs(ts) / 3600000;
|
||||
if (h < 1) return "var(--theme-green)";
|
||||
if (h < 24) return "var(--theme-orange)";
|
||||
return "var(--theme-red)";
|
||||
}
|
||||
```
|
||||
|
||||
## Registered Repos — Sync Status
|
||||
|
||||
```js
|
||||
const activeRepos = repos.filter(r => r.status === "active");
|
||||
const staleCount = activeRepos.filter(r => !r.last_state_synced_at || ageMs(r.last_state_synced_at) > 86400000).length;
|
||||
const freshCount = activeRepos.filter(r => r.last_state_synced_at && ageMs(r.last_state_synced_at) < 3600000).length;
|
||||
```
|
||||
|
||||
```js
|
||||
display(html`
|
||||
<div style="display:flex;gap:1.5rem;margin-bottom:1.5rem">
|
||||
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
||||
<div style="font-size:2rem;font-weight:700;color:var(--theme-green)">${freshCount}</div>
|
||||
<div style="font-size:0.8rem;color:#666">synced < 1h</div>
|
||||
</div>
|
||||
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
||||
<div style="font-size:2rem;font-weight:700;color:var(--theme-red)">${staleCount}</div>
|
||||
<div style="font-size:0.8rem;color:#666">stale / never</div>
|
||||
</div>
|
||||
<div style="padding:1rem 1.5rem;border-radius:8px;background:#f5f5f5;min-width:100px;text-align:center">
|
||||
<div style="font-size:2rem;font-weight:700">${activeRepos.length}</div>
|
||||
<div style="font-size:0.8rem;color:#666">total active</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
```
|
||||
|
||||
```js
|
||||
const table = html`<table style="width:100%;border-collapse:collapse;font-size:0.9rem">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #ddd">
|
||||
<th style="text-align:left;padding:6px 8px">Repo</th>
|
||||
<th style="text-align:left;padding:6px 8px">Domain</th>
|
||||
<th style="text-align:left;padding:6px 8px">Last Synced</th>
|
||||
<th style="text-align:left;padding:6px 8px">Last SBOM</th>
|
||||
<th style="text-align:left;padding:6px 8px">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${activeRepos
|
||||
.sort((a, b) => ageMs(a.last_state_synced_at) - ageMs(b.last_state_synced_at))
|
||||
.map(r => html`<tr style="border-bottom:1px solid #eee">
|
||||
<td style="padding:6px 8px;font-weight:500">${r.slug}</td>
|
||||
<td style="padding:6px 8px;color:#555">${r.domain_slug}</td>
|
||||
<td style="padding:6px 8px;color:${syncColor(r.last_state_synced_at)};font-weight:500">${fmtAge(r.last_state_synced_at)}</td>
|
||||
<td style="padding:6px 8px;color:#777">${fmtAge(r.last_sbom_at)}</td>
|
||||
<td style="padding:6px 8px">
|
||||
<span style="padding:2px 8px;border-radius:12px;font-size:0.75rem;background:${r.status === 'active' ? '#e8f5e9' : '#f5f5f5'};color:${r.status === 'active' ? '#2e7d32' : '#666'}">${r.status}</span>
|
||||
</td>
|
||||
</tr>`)
|
||||
}
|
||||
</tbody>
|
||||
</table>`;
|
||||
display(table);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gitea Inventory — Unregistered Repos
|
||||
|
||||
_Repos on Gitea (`coulomb` org) not yet tracked by the state-hub._
|
||||
|
||||
```js
|
||||
const unregistered = inventory.unregistered ?? [];
|
||||
```
|
||||
|
||||
```js
|
||||
if (unregistered.length === 0) {
|
||||
display(html`<p style="color:var(--theme-green);font-weight:500">🎉 All Gitea repos are registered!</p>`);
|
||||
} else {
|
||||
display(html`
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.9rem">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #ddd">
|
||||
<th style="text-align:left;padding:6px 8px">Repo</th>
|
||||
<th style="text-align:left;padding:6px 8px">Language</th>
|
||||
<th style="text-align:left;padding:6px 8px">Description</th>
|
||||
<th style="text-align:left;padding:6px 8px">Onboard</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${unregistered.map(r => html`<tr style="border-bottom:1px solid #eee">
|
||||
<td style="padding:6px 8px;font-weight:500">
|
||||
<a href="${r.gitea_url}" target="_blank" style="color:inherit">${r.gitea_name}</a>
|
||||
</td>
|
||||
<td style="padding:6px 8px;color:#777">${r.language || "—"}</td>
|
||||
<td style="padding:6px 8px;color:#555">${r.description || "—"}</td>
|
||||
<td style="padding:6px 8px;font-size:0.75rem;color:#999">
|
||||
make register-project DOMAIN=? PROJECT_PATH=/home/worsch/${r.gitea_name}
|
||||
</td>
|
||||
</tr>`)}
|
||||
</tbody>
|
||||
</table>
|
||||
`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hub-Only Repos
|
||||
|
||||
_Registered in the state-hub but no matching Gitea repo found._
|
||||
|
||||
```js
|
||||
const hubOnly = inventory.hub_only ?? [];
|
||||
```
|
||||
|
||||
```js
|
||||
if (hubOnly.length === 0) {
|
||||
display(html`<p style="color:#666">None — all hub repos have a Gitea counterpart.</p>`);
|
||||
} else {
|
||||
display(html`<ul>${hubOnly.map(r => html`<li><code>${r.slug}</code> — domain: ${r.domain}, status: ${r.status}</li>`)}</ul>`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
_Sync legend: 🟢 < 1h 🟠 1–24h 🔴 > 24h or never_
|
||||
|
||||
_Gitea token required for full inventory — set <code>GITEA_TOKEN</code> in <code>state-hub/.env</code>._
|
||||
Reference in New Issue
Block a user