generated from coulomb/repo-seed
ReviewDecision domain model
This commit is contained in:
@@ -36,6 +36,16 @@ class AnalysisRun:
|
|||||||
scanner_version: str
|
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)
|
@dataclass(frozen=True)
|
||||||
class ObservedFact:
|
class ObservedFact:
|
||||||
id: int
|
id: int
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from repo_registry.core.models import (
|
|||||||
ObservedFact,
|
ObservedFact,
|
||||||
Repository,
|
Repository,
|
||||||
RepositoryAbilityMap,
|
RepositoryAbilityMap,
|
||||||
|
ReviewDecision,
|
||||||
ScanSummary,
|
ScanSummary,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
)
|
)
|
||||||
@@ -112,6 +113,13 @@ class RegistryService:
|
|||||||
def list_capabilities(self) -> list[CapabilitySummary]:
|
def list_capabilities(self) -> list[CapabilitySummary]:
|
||||||
return self.store.list_capabilities()
|
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(
|
def list_observed_facts(
|
||||||
self,
|
self,
|
||||||
repository_id: int,
|
repository_id: int,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from repo_registry.core.models import (
|
|||||||
Repository,
|
Repository,
|
||||||
RepositoryAbilityMap,
|
RepositoryAbilityMap,
|
||||||
RepositorySnapshot,
|
RepositorySnapshot,
|
||||||
|
ReviewDecision,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
SourceReference,
|
SourceReference,
|
||||||
)
|
)
|
||||||
@@ -929,6 +930,40 @@ class RegistryStore:
|
|||||||
)
|
)
|
||||||
return int(cursor.lastrowid)
|
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(
|
def fail_analysis_run(
|
||||||
self,
|
self,
|
||||||
repository_id: int,
|
repository_id: int,
|
||||||
|
|||||||
@@ -344,6 +344,38 @@ def get_analysis_run(
|
|||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
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")
|
@app.get("/repos/{repository_id}/observed-facts")
|
||||||
def list_observed_facts(
|
def list_observed_facts(
|
||||||
repository_id: int,
|
repository_id: int,
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ def repository_detail(
|
|||||||
repository = service.get_repository(repository_id)
|
repository = service.get_repository(repository_id)
|
||||||
runs = service.list_analysis_runs(repository_id)
|
runs = service.list_analysis_runs(repository_id)
|
||||||
ability_map = service.ability_map(repository_id)
|
ability_map = service.ability_map(repository_id)
|
||||||
|
decisions = service.list_review_decisions(repository_id)
|
||||||
facts = service.list_observed_facts(repository_id)
|
facts = service.list_observed_facts(repository_id)
|
||||||
languages = sorted({fact.name for fact in facts if fact.kind == "language"})
|
languages = sorted({fact.name for fact in facts if fact.kind == "language"})
|
||||||
frameworks = sorted({fact.name for fact in facts if fact.kind == "framework"})
|
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))}
|
{render_ability_map(asdict(ability_map))}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="panel" style="margin-top:18px">
|
||||||
|
<h2>Review Decisions</h2>
|
||||||
|
{render_review_decisions(decisions)}
|
||||||
|
</section>
|
||||||
"""
|
"""
|
||||||
return page(repository.name, body)
|
return page(repository.name, body)
|
||||||
|
|
||||||
@@ -336,6 +341,7 @@ def analysis_run_detail(
|
|||||||
repository = service.get_repository(repository_id)
|
repository = service.get_repository(repository_id)
|
||||||
candidate_graph = service.candidate_graph(repository_id, analysis_run_id)
|
candidate_graph = service.candidate_graph(repository_id, analysis_run_id)
|
||||||
facts = service.list_observed_facts(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(
|
fact_rows = "\n".join(
|
||||||
f"""
|
f"""
|
||||||
<tr>
|
<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>
|
<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>
|
<tbody>{fact_rows or '<tr><td colspan="4" class="muted">No observed facts.</td></tr>'}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<h2>Review Decisions</h2>
|
||||||
|
{render_review_decisions(decisions)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>'
|
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(
|
def render_candidate_ability_actions(
|
||||||
ability: dict,
|
ability: dict,
|
||||||
repository_id: int,
|
repository_id: int,
|
||||||
|
|||||||
@@ -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)
|
candidate_graph = service.candidate_graph(repository.id, summary.analysis_run.id)
|
||||||
assert candidate_graph.abilities[0].status == "approved"
|
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):
|
def test_reject_candidate_ability_excludes_it_from_approval(tmp_path):
|
||||||
|
|||||||
@@ -155,6 +155,17 @@ def test_api_analysis_run_loop(tmp_path):
|
|||||||
)
|
)
|
||||||
assert reject_response.status_code == 200
|
assert reject_response.status_code == 200
|
||||||
assert reject_response.json()["abilities"][0]["status"] == "rejected"
|
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={})
|
run_response = client.post(f"/repos/{repository_id}/analysis-runs", json={})
|
||||||
assert run_response.status_code == 201
|
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 run_detail.status_code == 200
|
||||||
assert "Candidate Graph" in run_detail.text
|
assert "Candidate Graph" in run_detail.text
|
||||||
assert "ID " in run_detail.text
|
assert "ID " in run_detail.text
|
||||||
|
assert "No review decisions yet." in run_detail.text
|
||||||
|
|
||||||
approve_response = client.post(
|
approve_response = client.post(
|
||||||
f"{run_path}/candidate-graph/approve",
|
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 "Language: Python" in approved_detail.text
|
||||||
assert "Framework: FastAPI" in approved_detail.text
|
assert "Framework: FastAPI" in approved_detail.text
|
||||||
assert "interface:app.py:3" 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"})
|
search_response = client.get("/ui/search", params={"q": "repository"})
|
||||||
assert search_response.status_code == 200
|
assert search_response.status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user