ReviewDecision domain model

This commit is contained in:
2026-04-26 00:24:02 +02:00
parent 5aa76af78c
commit 29e855e5b3
7 changed files with 130 additions and 0 deletions

View File

@@ -36,6 +36,16 @@ class AnalysisRun:
scanner_version: str
@dataclass(frozen=True)
class ReviewDecision:
id: int
repository_id: int
analysis_run_id: int | None
action: str
notes: str
created_at: str
@dataclass(frozen=True)
class ObservedFact:
id: int

View File

@@ -10,6 +10,7 @@ from repo_registry.core.models import (
ObservedFact,
Repository,
RepositoryAbilityMap,
ReviewDecision,
ScanSummary,
SearchResult,
)
@@ -112,6 +113,13 @@ class RegistryService:
def list_capabilities(self) -> list[CapabilitySummary]:
return self.store.list_capabilities()
def list_review_decisions(
self,
repository_id: int,
analysis_run_id: int | None = None,
) -> list[ReviewDecision]:
return self.store.list_review_decisions(repository_id, analysis_run_id)
def list_observed_facts(
self,
repository_id: int,

View File

@@ -21,6 +21,7 @@ from repo_registry.core.models import (
Repository,
RepositoryAbilityMap,
RepositorySnapshot,
ReviewDecision,
SearchResult,
SourceReference,
)
@@ -929,6 +930,40 @@ class RegistryStore:
)
return int(cursor.lastrowid)
def list_review_decisions(
self,
repository_id: int,
analysis_run_id: int | None = None,
) -> list[ReviewDecision]:
self.get_repository(repository_id)
params: tuple[int, ...]
where = "WHERE repository_id = ?"
params = (repository_id,)
if analysis_run_id is not None:
where += " AND analysis_run_id = ?"
params = (repository_id, analysis_run_id)
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT id, repository_id, analysis_run_id, action, notes, created_at
FROM review_decisions
{where}
ORDER BY created_at DESC, id DESC
""",
params,
).fetchall()
return [
ReviewDecision(
id=row["id"],
repository_id=row["repository_id"],
analysis_run_id=row["analysis_run_id"],
action=row["action"],
notes=row["notes"],
created_at=row["created_at"],
)
for row in rows
]
def fail_analysis_run(
self,
repository_id: int,

View File

@@ -344,6 +344,38 @@ def get_analysis_run(
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/repos/{repository_id}/review-decisions")
def list_repository_review_decisions(
repository_id: int,
service: RegistryService = Depends(get_service),
) -> list[dict[str, object]]:
try:
return [
asdict(decision)
for decision in service.list_review_decisions(repository_id)
]
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/repos/{repository_id}/analysis-runs/{analysis_run_id}/review-decisions")
def list_analysis_run_review_decisions(
repository_id: int,
analysis_run_id: int,
service: RegistryService = Depends(get_service),
) -> list[dict[str, object]]:
try:
return [
asdict(decision)
for decision in service.list_review_decisions(
repository_id,
analysis_run_id,
)
]
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/repos/{repository_id}/observed-facts")
def list_observed_facts(
repository_id: int,

View File

@@ -267,6 +267,7 @@ def repository_detail(
repository = service.get_repository(repository_id)
runs = service.list_analysis_runs(repository_id)
ability_map = service.ability_map(repository_id)
decisions = service.list_review_decisions(repository_id)
facts = service.list_observed_facts(repository_id)
languages = sorted({fact.name for fact in facts if fact.kind == "language"})
frameworks = sorted({fact.name for fact in facts if fact.kind == "framework"})
@@ -307,6 +308,10 @@ def repository_detail(
{render_ability_map(asdict(ability_map))}
</section>
</div>
<section class="panel" style="margin-top:18px">
<h2>Review Decisions</h2>
{render_review_decisions(decisions)}
</section>
"""
return page(repository.name, body)
@@ -336,6 +341,7 @@ def analysis_run_detail(
repository = service.get_repository(repository_id)
candidate_graph = service.candidate_graph(repository_id, analysis_run_id)
facts = service.list_observed_facts(repository_id, analysis_run_id)
decisions = service.list_review_decisions(repository_id, analysis_run_id)
fact_rows = "\n".join(
f"""
<tr>
@@ -368,6 +374,8 @@ def analysis_run_detail(
<thead><tr><th>Kind</th><th>Name</th><th>Path</th><th>Value</th></tr></thead>
<tbody>{fact_rows or '<tr><td colspan="4" class="muted">No observed facts.</td></tr>'}</tbody>
</table>
<h2>Review Decisions</h2>
{render_review_decisions(decisions)}
</section>
</div>
"""
@@ -743,6 +751,27 @@ def render_repository_facts(languages: list[str], frameworks: list[str]) -> str:
return f'<p class="actions">{language_pills}{framework_pills}</p>'
def render_review_decisions(decisions: list) -> str:
if not decisions:
return '<p class="muted">No review decisions yet.</p>'
rows = "\n".join(
f"""
<tr>
<td>{escape(decision.action)}</td>
<td class="source">{escape(decision.created_at)}</td>
<td>{escape(decision.notes)}</td>
</tr>
"""
for decision in decisions
)
return f"""
<table>
<thead><tr><th>Action</th><th>Created</th><th>Notes</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def render_candidate_ability_actions(
ability: dict,
repository_id: int,

View File

@@ -303,6 +303,9 @@ def test_approve_candidate_graph_publishes_ability_map_once(tmp_path):
candidate_graph = service.candidate_graph(repository.id, summary.analysis_run.id)
assert candidate_graph.abilities[0].status == "approved"
decisions = service.list_review_decisions(repository.id, summary.analysis_run.id)
assert decisions[0].action == "approve_candidate_graph"
assert decisions[0].notes == "Looks good for the first pass."
def test_reject_candidate_ability_excludes_it_from_approval(tmp_path):

View File

@@ -155,6 +155,17 @@ def test_api_analysis_run_loop(tmp_path):
)
assert reject_response.status_code == 200
assert reject_response.json()["abilities"][0]["status"] == "rejected"
decisions_response = client.get(f"/repos/{repository_id}/review-decisions")
assert decisions_response.status_code == 200
assert decisions_response.json()[0]["action"] == "reject_candidate_ability"
run_decisions_response = client.get(
f"/repos/{repository_id}/analysis-runs/"
f"{run['analysis_run']['id']}/review-decisions"
)
assert run_decisions_response.status_code == 200
assert run_decisions_response.json()[0]["notes"] == (
"Reject once to exercise review correction."
)
run_response = client.post(f"/repos/{repository_id}/analysis-runs", json={})
assert run_response.status_code == 201
@@ -302,6 +313,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert run_detail.status_code == 200
assert "Candidate Graph" in run_detail.text
assert "ID " in run_detail.text
assert "No review decisions yet." in run_detail.text
approve_response = client.post(
f"{run_path}/candidate-graph/approve",
@@ -316,6 +328,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "Language: Python" in approved_detail.text
assert "Framework: FastAPI" in approved_detail.text
assert "interface:app.py:3" in approved_detail.text
assert "approve_candidate_graph" in approved_detail.text
search_response = client.get("/ui/search", params={"q": "repository"})
assert search_response.status_code == 200