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.
This commit is contained in:
parent
2d6b5f0150
commit
612ec66a00
2 changed files with 129 additions and 0 deletions
|
|
@ -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])
|
@router.get("/doi/summary", response_model=list[DoISummaryEntry])
|
||||||
async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]:
|
async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]:
|
||||||
"""Return DoI tier for all active repos, worst tier first.
|
"""Return DoI tier for all active repos, worst tier first.
|
||||||
|
|
|
||||||
64
tests/test_todo_md_staleness.py
Normal file
64
tests/test_todo_md_staleness.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue