diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index 37603e9..22aef0f 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -126,6 +126,8 @@ def _split_audit_list(value: str) -> list[str]: def _reviewer_type(action: str) -> str: + if action == "quality_gate_override": + return "human" if action.startswith("agentic_"): return "agent" if action == "trusted_auto_approve_candidate_graph": @@ -152,6 +154,8 @@ def _decision_kind(action: str) -> str: return "downgraded" if "request_human_review" in action: return "needs_human" + if "override" in action: + return "override" if "propose_edit" in action: return "proposed_edit" if "relink" in action: diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index efa65ee..b46b6ea 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -313,6 +313,37 @@ class RegistryService: "review without approving registry truth." ) + def record_quality_gate_override( + self, + repository_id: int, + analysis_run_id: int, + *, + criterion_id: str, + element_type: str, + element_id: int, + reason: str, + notes: str = "", + ) -> ReviewDecision: + if not reason.strip(): + raise ValueError("quality gate override requires a reason") + decision_id = self.store.create_review_decision( + repository_id, + analysis_run_id, + action="quality_gate_override", + notes=( + f"criteria_version={active_quality_criteria_version()}; " + f"criteria={criterion_id}; " + f"target={element_type}:{element_id}; " + f"rationale={reason.strip()}; " + f"notes={notes.strip()}" + ).strip(), + ) + return next( + decision + for decision in self.list_review_decisions(repository_id, analysis_run_id) + if decision.id == decision_id + ) + def _generate_candidates( self, repository: Repository, diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index a992094..7a3bb55 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -65,6 +65,7 @@ from repo_registry.web_api.schemas import ( IdResponse, ObservedFactResponse, QualityCriteriaRegistryResponse, + QualityGateOverrideCreate, RepositoryAbilityMapResponse, RepositoryComparisonResponse, RepositoryCreate, @@ -471,6 +472,29 @@ def list_analysis_run_review_decisions( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/quality-gate-overrides", + tags=["review"], + response_model=ReviewDecisionResponse, +) +def create_quality_gate_override( + repository_id: int, + analysis_run_id: int, + payload: QualityGateOverrideCreate, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.record_quality_gate_override( + repository_id, + analysis_run_id, + **payload.model_dump(), + ) + ) + except (NotFoundError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get( "/repos/{repository_id}/observed-facts", tags=["analysis"], diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index a88dc95..988278e 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -566,6 +566,14 @@ class QualityCriteriaRegistryResponse(BaseModel): criteria: list[QualityCriterionResponse] +class QualityGateOverrideCreate(BaseModel): + criterion_id: str + element_type: str + element_id: int + reason: str + notes: str = "" + + class ObservedFactResponse(BaseModel): id: int repository_id: int diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 4900dfd..6630c9b 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -9,6 +9,10 @@ from urllib.parse import quote_plus, urlparse from fastapi import APIRouter, Depends, Form, HTTPException, Query from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse +from repo_registry.acceptance import ( + evaluate_candidate_graph_quality, + quality_gate_outcome_dicts, +) from repo_registry.core.service import RegistryService from repo_registry.self_scoping.comparison import compare_assessment_to_golden from repo_registry.self_scoping.review_store import ( @@ -2018,6 +2022,10 @@ def analysis_run_detail( display_name = repository_display_name(repository) candidate_graph = service.candidate_graph(repository_id, analysis_run_id) candidate_graph_data = asdict(candidate_graph) + quality_gate_outcomes = quality_gate_outcome_dicts( + evaluate_candidate_graph_quality(candidate_graph) + ) + candidate_graph_data["quality_gate_outcomes"] = quality_gate_outcomes facts = service.list_observed_facts(repository_id, analysis_run_id) chunks = service.list_content_chunks(repository_id, analysis_run_id) decisions = service.list_review_decisions(repository_id, analysis_run_id) @@ -2070,6 +2078,12 @@ def analysis_run_detail( {render_candidate_graph(candidate_graph_data, repository_id, analysis_run_id)} +
No quality gates fired for this run.
' + rows = "\n".join( + f""" +| Criterion | Outcome | Element | Reason | Override |
|---|