From 612ec66a00891d8dae2123ee0b0a1ca89f4f496e Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 8 Jul 2026 13:19:06 +0200 Subject: [PATCH] Add GET /repos/todo-md-staleness for daily TODO.md review Scan registered repos for TODO.md files unchanged longer than a configurable threshold (default 6 days). Feeds activity-core stale-review. --- api/routers/repos.py | 65 +++++++++++++++++++++++++++++++++ tests/test_todo_md_staleness.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/test_todo_md_staleness.py diff --git a/api/routers/repos.py b/api/routers/repos.py index 812d0c2..c9c9e9f 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -253,6 +253,71 @@ async def onboard_repo(body: RepoOnboardRequest) -> RepoOnboardResult: ) +def _todo_md_entry(repo_slug: str, todo_path: Path, mtime: datetime) -> dict: + if mtime.tzinfo is None: + mtime = mtime.replace(tzinfo=timezone.utc) + age_days = max(0, (datetime.now(timezone.utc) - mtime).days) + return { + "repo_slug": repo_slug, + "has_todo": True, + "todo_path": str(todo_path), + "mtime": mtime.isoformat(), + "age_days": age_days, + } + + +@router.get("/todo-md-staleness") +async def list_todo_md_staleness( + stale_days: int = 6, + session: AsyncSession = Depends(get_session), +) -> dict: + """Scan registered repos for TODO.md files unchanged longer than stale_days. + + Used by activity-core daily stale-review automation. Only repos with an + accessible checkout and an existing TODO.md are included. + """ + if stale_days < 1: + raise HTTPException(status_code=400, detail="stale_days must be >= 1") + + result = await session.execute( + select(ManagedRepo, Domain.slug) + .join(Domain, Domain.id == ManagedRepo.domain_id) + .where(ManagedRepo.status == "active") + .order_by(ManagedRepo.slug) + ) + + all_with_todo: list[dict] = [] + scanned = 0 + for repo, _domain_slug in result.all(): + scanned += 1 + repo_dict = _repo_doi_dict(repo, _domain_slug) + resolved_path = resolve_repo_path(repo_dict) + if not resolved_path: + continue + todo_path = Path(resolved_path) / "TODO.md" + if not todo_path.is_file(): + continue + mtime = datetime.fromtimestamp(todo_path.stat().st_mtime, tz=timezone.utc) + all_with_todo.append(_todo_md_entry(repo.slug, todo_path, mtime)) + + stale_entries = [e for e in all_with_todo if e["age_days"] >= stale_days] + stale_count = len(stale_entries) + worst = max(stale_entries, key=lambda e: e["age_days"], default=None) + payload: dict = { + "stale_days": stale_days, + "repos": stale_entries, + "stale_count": stale_count, + "total_with_todo": len(all_with_todo), + "total_scanned": scanned, + "worst_repo_slug": worst["repo_slug"] if worst else None, + "worst_age_days": worst["age_days"] if worst else None, + "worst_mtime": worst["mtime"] if worst else None, + } + if worst: + payload.update(worst) + return payload + + @router.get("/doi/summary", response_model=list[DoISummaryEntry]) async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]: """Return DoI tier for all active repos, worst tier first. diff --git a/tests/test_todo_md_staleness.py b/tests/test_todo_md_staleness.py new file mode 100644 index 0000000..2998966 --- /dev/null +++ b/tests/test_todo_md_staleness.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import time + +import pytest + + +async def _create_domain(client, slug="tododom"): + r = await client.post("/domains/", json={"slug": slug, "name": "Todo Domain"}) + assert r.status_code == 201, r.text + return r.json() + + +async def _create_repo(client, domain_slug, local_path, slug="todo-repo"): + r = await client.post("/repos/", json={ + "slug": slug, + "name": "Todo Repo", + "domain_slug": domain_slug, + "local_path": str(local_path), + "remote_url": f"https://example.invalid/{slug}.git", + }) + assert r.status_code == 201, r.text + return r.json() + + +@pytest.mark.asyncio +async def test_todo_md_staleness_flags_unchanged_file(client, tmp_path): + domain = await _create_domain(client) + repo_root = tmp_path / "stale-repo" + repo_root.mkdir() + todo = repo_root / "TODO.md" + todo.write_text("# TODO\n\n- [ ] item\n", encoding="utf-8") + old = time.time() - (8 * 24 * 60 * 60) + import os + os.utime(todo, (old, old)) + + await _create_repo(client, domain["slug"], repo_root, slug="stale-repo") + + r = await client.get("/repos/todo-md-staleness?stale_days=6") + assert r.status_code == 200, r.text + payload = r.json() + assert payload["stale_count"] == 1 + assert payload["repos"][0]["repo_slug"] == "stale-repo" + assert payload["repos"][0]["age_days"] >= 6 + + +@pytest.mark.asyncio +async def test_todo_md_staleness_ignores_missing_and_fresh(client, tmp_path): + domain = await _create_domain(client) + + fresh_root = tmp_path / "fresh-repo" + fresh_root.mkdir() + (fresh_root / "TODO.md").write_text("# TODO\n", encoding="utf-8") + await _create_repo(client, domain["slug"], fresh_root, slug="fresh-repo") + + bare_root = tmp_path / "bare-repo" + bare_root.mkdir() + await _create_repo(client, domain["slug"], bare_root, slug="bare-repo") + + r = await client.get("/repos/todo-md-staleness?stale_days=6") + assert r.status_code == 200, r.text + payload = r.json() + assert payload["stale_count"] == 0 + assert payload["total_with_todo"] == 1 \ No newline at end of file