generated from coulomb/repo-seed
Improve dashboard loading and task sync
This commit is contained in:
@@ -28,10 +28,11 @@ export async function waitForVisible(ms) {
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const url = path.startsWith("http") ? path : `${API}${path}`;
|
||||
const timeout = options.timeout ?? FETCH_TIMEOUT;
|
||||
const {timeout: _timeout, ...fetchOptions} = options;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), timeout);
|
||||
try {
|
||||
return await fetch(url, {...options, signal: ctrl.signal});
|
||||
return await fetch(url, {cache: "no-store", ...fetchOptions, signal: ctrl.signal});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -20,61 +20,69 @@ const pageState = (async function*() {
|
||||
while (true) {
|
||||
let summary = {}, snapshots = [], totalPkgs = 0, milestones = [], wsAll = [], ok = false;
|
||||
try {
|
||||
const [rSum, rSnap, rRegs, rw, rt, rto, rr, rwi] = await Promise.all([
|
||||
apiFetch("/state/summary", {timeout: 20_000}),
|
||||
apiFetch("/sbom/snapshots/"),
|
||||
apiFetch("/progress/?event_type=milestone&limit=500"),
|
||||
apiFetch("/workstreams/"),
|
||||
apiFetch("/tasks/?limit=2000"),
|
||||
apiFetch("/topics/"),
|
||||
apiFetch("/repos/"),
|
||||
apiFetch("/workstreams/workplan-index"),
|
||||
const loadJson = async (name, path, options = {}) => {
|
||||
const response = await apiFetch(path, options);
|
||||
if (!response.ok) throw new Error(`${name} HTTP ${response.status}`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const [
|
||||
summaryData,
|
||||
snapList,
|
||||
allEvents,
|
||||
wsList,
|
||||
taskList,
|
||||
topicList,
|
||||
repoList,
|
||||
workplanIndex,
|
||||
] = await Promise.all([
|
||||
loadJson("summary", "/state/summary", {timeout: 20_000}),
|
||||
loadJson("sbom snapshots", "/sbom/snapshots/"),
|
||||
loadJson("milestones", "/progress/?event_type=milestone&limit=500"),
|
||||
loadJson("workstreams", "/workstreams/"),
|
||||
loadJson("tasks", "/tasks/?limit=2000"),
|
||||
loadJson("topics", "/topics/"),
|
||||
loadJson("repos", "/repos/"),
|
||||
loadJson("workplan index", "/workstreams/workplan-index").catch(() => ({workstreams: {}})),
|
||||
]);
|
||||
ok = rSum.ok && rSnap.ok && rRegs.ok && rw.ok && rt.ok && rto.ok && rr.ok;
|
||||
if (ok) {
|
||||
const [summaryData, snapList, allEvents, wsList, taskList, topicList, repoList] = await Promise.all([
|
||||
rSum.json(), rSnap.json(), rRegs.json(), rw.json(), rt.json(), rto.json(), rr.json(),
|
||||
]);
|
||||
summary = summaryData;
|
||||
snapshots = snapList;
|
||||
totalPkgs = snapshots.reduce((s, sn) => s + (sn.entry_count ?? 0), 0);
|
||||
milestones = allEvents.filter(e => e.summary?.startsWith("Project registered with State Hub:"));
|
||||
const workplanIndex = rwi.ok ? await rwi.json() : {workstreams: {}};
|
||||
const workplanMap = workplanIndex.workstreams ?? {};
|
||||
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
|
||||
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
||||
const counts = {};
|
||||
for (const t of taskList) {
|
||||
const wid = t.workstream_id;
|
||||
if (!counts[wid]) counts[wid] = {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0};
|
||||
counts[wid].total++;
|
||||
if (t.status === "done") counts[wid].done++;
|
||||
else if (t.status === "in_progress") counts[wid].in_progress++;
|
||||
else if (t.status === "blocked") counts[wid].blocked++;
|
||||
else if (t.status === "todo") counts[wid].todo++;
|
||||
}
|
||||
wsAll = wsList.map(w => {
|
||||
const repo = repoMap[w.repo_id];
|
||||
const topic = topicMap[w.topic_id];
|
||||
const workplan = workplanMap[w.id] ?? {};
|
||||
return {
|
||||
...w,
|
||||
status: normalizeWorkstreamStatus(w.status),
|
||||
domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown",
|
||||
repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned",
|
||||
workplan_filename: workplan.filename ?? null,
|
||||
workplan_relative_path: workplan.relative_path ?? null,
|
||||
workplan_archived: workplan.archived ?? false,
|
||||
health_labels: workplan.health_labels ?? [],
|
||||
href: `./workstreams/${w.id}`,
|
||||
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
summary = {error: "API unreachable"};
|
||||
|
||||
ok = true;
|
||||
summary = summaryData;
|
||||
snapshots = snapList;
|
||||
totalPkgs = snapshots.reduce((s, sn) => s + (sn.entry_count ?? 0), 0);
|
||||
milestones = allEvents.filter(e => e.summary?.startsWith("Project registered with State Hub:"));
|
||||
const workplanMap = workplanIndex.workstreams ?? {};
|
||||
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
|
||||
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
||||
const counts = {};
|
||||
for (const t of taskList) {
|
||||
const wid = t.workstream_id;
|
||||
if (!counts[wid]) counts[wid] = {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0};
|
||||
counts[wid].total++;
|
||||
if (t.status === "done") counts[wid].done++;
|
||||
else if (t.status === "in_progress") counts[wid].in_progress++;
|
||||
else if (t.status === "blocked") counts[wid].blocked++;
|
||||
else if (t.status === "todo") counts[wid].todo++;
|
||||
}
|
||||
wsAll = wsList.map(w => {
|
||||
const repo = repoMap[w.repo_id];
|
||||
const topic = topicMap[w.topic_id];
|
||||
const workplan = workplanMap[w.id] ?? {};
|
||||
return {
|
||||
...w,
|
||||
status: normalizeWorkstreamStatus(w.status),
|
||||
domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown",
|
||||
repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned",
|
||||
workplan_filename: workplan.filename ?? null,
|
||||
workplan_relative_path: workplan.relative_path ?? null,
|
||||
workplan_archived: workplan.archived ?? false,
|
||||
health_labels: workplan.health_labels ?? [],
|
||||
href: `./workstreams/${w.id}`,
|
||||
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
summary = {error: "API unreachable"};
|
||||
summary = {error: `Dashboard data load failed: ${e?.message ?? String(e)}`};
|
||||
}
|
||||
failures = ok ? 0 : failures + 1;
|
||||
yield {summary, snapshots, totalPkgs, milestones, wsAll, ok, ts: new Date()};
|
||||
@@ -136,8 +144,8 @@ display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}"
|
||||
// it displays the element AND returns a reactive value that re-runs dependent blocks.
|
||||
const _chartMode = view(html`<select class="ws-mode-select">
|
||||
<optgroup label="Lifecycle">
|
||||
<option value="ready" selected>ready</option>
|
||||
<option value="active">active</option>
|
||||
<option value="ready">ready</option>
|
||||
<option value="active" selected>active</option>
|
||||
<option value="blocked">blocked</option>
|
||||
<option value="proposed">proposed</option>
|
||||
<option value="backlog">backlog</option>
|
||||
|
||||
@@ -24,6 +24,7 @@ Checks:
|
||||
C-17 repo-ahead-push-failed WARN No Local repo has unpushed commits and push failed — writes skipped to prevent runaway divergence
|
||||
C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB
|
||||
C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph
|
||||
C-22 task-description-drift WARN Yes Task description/content differs between file and DB
|
||||
|
||||
Usage:
|
||||
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
|
||||
@@ -83,7 +84,7 @@ except ImportError:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
|
||||
_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE)
|
||||
_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE)
|
||||
_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
|
||||
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
|
||||
SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES)
|
||||
@@ -236,16 +237,64 @@ def parse_frontmatter(text: str) -> tuple[dict, str]:
|
||||
return meta, parts[2]
|
||||
|
||||
|
||||
def _clean_task_description(raw: str) -> str | None:
|
||||
"""Trim section chrome while preserving task markdown content."""
|
||||
lines = raw.splitlines()
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
while lines and lines[-1].strip() in {"---", "***", "___"}:
|
||||
lines.pop()
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
while lines and lines[0].strip() in {"---", "***", "___"}:
|
||||
lines.pop(0)
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
text = "\n".join(lines).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def parse_task_blocks(body: str) -> list[dict]:
|
||||
"""Extract task blocks, injecting title from the nearest preceding ### heading."""
|
||||
headings = [(m.start(), m.group(1).strip()) for m in _HEADING_RE.finditer(body)]
|
||||
"""Extract task blocks, injecting title and markdown content from the task section."""
|
||||
headings = [
|
||||
(m.start(), len(m.group(1)), m.group(2).strip())
|
||||
for m in _HEADING_RE.finditer(body)
|
||||
]
|
||||
task_matches = list(_TASK_BLOCK_RE.finditer(body))
|
||||
results = []
|
||||
for m in _TASK_BLOCK_RE.finditer(body):
|
||||
for idx, m in enumerate(task_matches):
|
||||
task = _parse_yaml_block(m.group(1).strip())
|
||||
prev = [(pos, level, text) for pos, level, text in headings if pos < m.start()]
|
||||
prev_heading = prev[-1] if prev else None
|
||||
if "title" not in task:
|
||||
prev = [(pos, text) for pos, text in headings if pos < m.start()]
|
||||
if prev:
|
||||
task["title"] = prev[-1][1]
|
||||
if prev_heading:
|
||||
task["title"] = prev_heading[2]
|
||||
|
||||
content_end = len(body)
|
||||
if idx + 1 < len(task_matches):
|
||||
content_end = min(content_end, task_matches[idx + 1].start())
|
||||
if prev_heading:
|
||||
current_level = prev_heading[1]
|
||||
next_peer_heading = next(
|
||||
(
|
||||
pos
|
||||
for pos, level, _text in headings
|
||||
if pos > m.end() and level <= current_level
|
||||
),
|
||||
None,
|
||||
)
|
||||
if next_peer_heading is not None:
|
||||
content_end = min(content_end, next_peer_heading)
|
||||
else:
|
||||
next_heading = next((pos for pos, _level, _text in headings if pos > m.end()), None)
|
||||
if next_heading is not None:
|
||||
content_end = min(content_end, next_heading)
|
||||
|
||||
description = _clean_task_description(body[m.end():content_end])
|
||||
if description:
|
||||
task["description"] = description
|
||||
results.append(task)
|
||||
return results
|
||||
|
||||
@@ -900,6 +949,27 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
||||
fixable=True,
|
||||
_fix_context={"task_id": t_sh_id, "status": t_status},
|
||||
)
|
||||
file_description = task.get("description")
|
||||
if isinstance(file_description, str):
|
||||
file_description = file_description.strip() or None
|
||||
else:
|
||||
file_description = None
|
||||
db_description = db_task.get("description")
|
||||
if isinstance(db_description, str):
|
||||
db_description = db_description.strip() or None
|
||||
else:
|
||||
db_description = None
|
||||
if file_description and file_description != db_description:
|
||||
report.add(
|
||||
severity="WARN", check_id="C-22",
|
||||
message=f"Task description drift '{t_id}': file content differs from DB (file wins)",
|
||||
file_path=f"{fname}#{t_id}",
|
||||
db_id=t_sh_id,
|
||||
file_value=file_description[:120],
|
||||
db_value=(db_description or "")[:120],
|
||||
fixable=True,
|
||||
_fix_context={"task_id": t_sh_id, "description": file_description},
|
||||
)
|
||||
elif t_id:
|
||||
# C-11: task exists in file but not linked to DB
|
||||
ws_status = ws.get("status", "")
|
||||
@@ -1478,6 +1548,7 @@ def fix_repo(
|
||||
t_data = _api_post(api_base, "/tasks", {
|
||||
"workstream_id": new_ws_id,
|
||||
"title": str(task.get("title", t_id)).strip() or t_id,
|
||||
"description": task.get("description") or None,
|
||||
"status": t_status,
|
||||
"priority": t_priority,
|
||||
"assignee": task.get("assignee") or None,
|
||||
@@ -1526,10 +1597,14 @@ def fix_repo(
|
||||
status = ctx["status"]
|
||||
result = _api_patch(api_base, f"/tasks/{task_id}",
|
||||
{"status": status})
|
||||
if result is not None:
|
||||
if result is not None and "_error" not in result:
|
||||
report.fixes_applied.append(
|
||||
f"C-10 fixed: task {task_id[:8]}… status → {status!r}"
|
||||
)
|
||||
elif result is not None:
|
||||
report.fixes_applied.append(
|
||||
f"C-10 FAILED: task {task_id[:8]}… status → {status!r}: {result['_error']}"
|
||||
)
|
||||
|
||||
elif issue.check_id == "C-11":
|
||||
ws_id = ctx["ws_id"]
|
||||
@@ -1555,6 +1630,7 @@ def fix_repo(
|
||||
t_data = _api_post(api_base, "/tasks", {
|
||||
"workstream_id": ws_id,
|
||||
"title": str(task.get("title", t_id)).strip() or t_id,
|
||||
"description": task.get("description") or None,
|
||||
"status": t_status,
|
||||
"priority": t_priority,
|
||||
"assignee": task.get("assignee") or None,
|
||||
@@ -1579,6 +1655,19 @@ def fix_repo(
|
||||
f"C-12 fixed: orphan task {task_id[:8]}… cancelled (workstream finished)"
|
||||
)
|
||||
|
||||
elif issue.check_id == "C-22":
|
||||
task_id = ctx["task_id"]
|
||||
description = ctx["description"]
|
||||
result = _api_patch(api_base, f"/tasks/{task_id}", {"description": description})
|
||||
if result is not None and "_error" not in result:
|
||||
report.fixes_applied.append(
|
||||
f"C-22 fixed: task {task_id[:8]}… description updated"
|
||||
)
|
||||
elif result is not None:
|
||||
report.fixes_applied.append(
|
||||
f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}"
|
||||
)
|
||||
|
||||
elif issue.check_id == "C-15":
|
||||
# T03 — writeback: DB is ahead of file — patch file to match DB.
|
||||
if no_writeback:
|
||||
|
||||
@@ -145,6 +145,39 @@ class TestParseTaskBlocks:
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["id"] == "T01"
|
||||
|
||||
def test_extracts_markdown_after_task_block_as_description(self):
|
||||
body = (
|
||||
"### T01 — Configure registry\n\n"
|
||||
"```task\nid: T01\nstatus: todo\npriority: high\n```\n\n"
|
||||
"Update `app.ini` so packages are enabled.\n\n"
|
||||
"```ini\n[packages]\nENABLED = true\n```\n\n"
|
||||
"**Done when:** a rendered config contains the package settings.\n\n"
|
||||
"---\n\n"
|
||||
"### T02 — Next task\n\n"
|
||||
"```task\nid: T02\nstatus: todo\n```\n"
|
||||
)
|
||||
tasks = parse_task_blocks(body)
|
||||
assert tasks[0]["description"] == (
|
||||
"Update `app.ini` so packages are enabled.\n\n"
|
||||
"```ini\n[packages]\nENABLED = true\n```\n\n"
|
||||
"**Done when:** a rendered config contains the package settings."
|
||||
)
|
||||
assert "Next task" not in tasks[0]["description"]
|
||||
|
||||
def test_keeps_nested_headings_inside_task_description(self):
|
||||
body = (
|
||||
"## T01\n\n"
|
||||
"```task\nid: T01\nstatus: todo\n```\n\n"
|
||||
"Intro.\n\n"
|
||||
"### Notes\n\n"
|
||||
"- Keep this nested heading in the task content.\n\n"
|
||||
"## T02\n\n"
|
||||
"```task\nid: T02\nstatus: todo\n```\n"
|
||||
)
|
||||
tasks = parse_task_blocks(body)
|
||||
assert "### Notes" in tasks[0]["description"]
|
||||
assert "## T02" not in tasks[0]["description"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_tasks_from_workplan
|
||||
|
||||
Reference in New Issue
Block a user