From 0970620722081db54055ce37eb490d56f3625f7f Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 26 Apr 2026 15:54:37 +0200 Subject: [PATCH] web UI side of change review --- src/repo_registry/web_ui/views.py | 158 +++++++++++++++++- tests/test_web_api.py | 44 +++++ .../RREG-WP-0002-production-hardening.md | 2 +- 3 files changed, 201 insertions(+), 3 deletions(-) diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index f4788eb..fa80c64 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -281,6 +281,7 @@ def repository_detail( #{run.id} {escape(run.status)} {escape(run.started_at)} + {render_run_compare_link(repository_id, run.id, runs)} {escape(run.error_message or '')} """ @@ -310,8 +311,8 @@ def repository_detail(

Analysis Runs

- - {run_rows or ''} + + {run_rows or ''}
RunStatusStartedError
No runs yet.
RunStatusStartedCompareError
No runs yet.
@@ -646,6 +647,7 @@ def analysis_run_detail( body = f"""

{escape(repository.name)} · Run #{analysis_run_id}

+ {render_run_detail_compare_link(repository_id, analysis_run_id, service.list_analysis_runs(repository_id))} Repository
@@ -676,6 +678,59 @@ def analysis_run_detail( return page(f"{repository.name} Run {analysis_run_id}", body) +@router.get("/ui/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}") +def analysis_run_diff_detail( + repository_id: int, + base_analysis_run_id: int, + target_analysis_run_id: int, + service: RegistryService = Depends(get_service), +) -> HTMLResponse: + try: + diff = service.diff_analysis_runs( + repository_id, + base_analysis_run_id, + target_analysis_run_id, + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + body = f""" +
+

{escape(diff.repository.name)} · Change Review

+ Target Run + Repository +
+

Comparing run #{base_analysis_run_id} to run #{target_analysis_run_id}.

+
+
+

Approve Target Changes

+
+ + +
+
+
+
+
+

Approved Registry Impact

+ {render_diff_section(asdict(diff.approved_entries))} +
+
+

Candidate Claims

+ {render_diff_section(asdict(diff.candidates))} +
+
+

Observed Facts

+ {render_diff_section(asdict(diff.facts))} +
+
+

Content Chunks

+ {render_diff_section(asdict(diff.chunks))} +
+
+ """ + return page(f"{diff.repository.name} Change Review", body) + + @router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve") def approve_candidate_graph_from_form( repository_id: int, @@ -690,6 +745,21 @@ def approve_candidate_graph_from_form( return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) +@router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/changes/approve") +def approve_analysis_run_changes_from_form( + repository_id: int, + analysis_run_id: int, + notes: str = Form("Approved target run changes from web UI"), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.approve_analysis_run_changes( + repository_id, + analysis_run_id, + notes=notes, + ) + return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) + + @router.post( "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{candidate_ability_id}/reject" @@ -1002,6 +1072,90 @@ def merge_candidate_evidence_from_form( ) +def render_run_compare_link(repository_id: int, run_id: int, runs: list) -> str: + base_run = next((run for run in runs if run.id < run_id), None) + if base_run is None: + return 'Baseline' + href = f"/ui/repos/{repository_id}/analysis-runs/{base_run.id}/diff/{run_id}" + return f'Compare to #{base_run.id}' + + +def render_run_detail_compare_link( + repository_id: int, + analysis_run_id: int, + runs: list, +) -> str: + base_run = next((run for run in runs if run.id < analysis_run_id), None) + if base_run is None: + return "" + href = f"/ui/repos/{repository_id}/analysis-runs/{base_run.id}/diff/{analysis_run_id}" + return f'Compare to #{base_run.id}' + + +def render_diff_section(section: dict) -> str: + groups = [ + ("added", "Added"), + ("removed", "Removed"), + ("changed", "Changed"), + ("weakened", "Weakened"), + ] + rendered = "".join( + render_diff_group(section.get(key, []), title) + for key, title in groups + ) + return rendered or '

No differences.

' + + +def render_diff_group(items: list[dict], title: str) -> str: + if not items: + return "" + rows = "\n".join( + f""" + + {escape(item['item_type'])} + {escape(item['key'])} + {render_diff_payload(item.get('base'))} + {render_diff_payload(item.get('target'))} + + """ + for item in items + ) + return f""" +

{escape(title)}

+ + + {rows} +
TypeKeyBaseTarget
+ """ + + +def render_diff_payload(payload: dict | None) -> str: + if payload is None: + return 'None' + preferred = [ + "name", + "description", + "confidence", + "strength", + "path", + "location", + "reference", + "value", + "text", + ] + parts = [] + for key in preferred: + if key not in payload or payload[key] in (None, "", []): + continue + value = str(payload[key]) + if key == "text" and len(value) > 180: + value = value[:180] + "..." + parts.append( + f'
{escape(key)}: {escape(value)}
' + ) + return "".join(parts) or 'No display fields.' + + def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str: abilities = graph.get("abilities", []) if not abilities: diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 3f0cbde..bab3e0e 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -841,6 +841,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): ) assert run_response.status_code == 303 run_path = run_response.headers["location"] + first_run_id = int(run_path.rsplit("/", 1)[-1]) run_detail = client.get(run_path) assert run_detail.status_code == 200 @@ -865,6 +866,49 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert "interface:app.py:3" in approved_detail.text assert "approve_candidate_graph" in approved_detail.text + (source / "app.py").write_text( + "from fastapi import FastAPI\n" + "app = FastAPI()\n" + '@app.get("/status")\n' + "def status():\n" + " return {}\n\n" + '@app.get("/ready")\n' + "def ready():\n" + " return {}\n", + encoding="utf-8", + ) + second_run_response = client.post( + f"{repository_path}/analysis-runs", + data={"source_path": ""}, + follow_redirects=False, + ) + assert second_run_response.status_code == 303 + second_run_path = second_run_response.headers["location"] + + second_run_detail = client.get(second_run_path) + assert second_run_detail.status_code == 200 + assert "Compare to #" in second_run_detail.text + + second_run_id = int(second_run_path.rsplit("/", 1)[-1]) + diff_response = client.get( + f"{repository_path}/analysis-runs/{first_run_id}/diff/{second_run_id}" + ) + assert diff_response.status_code == 200 + assert "Change Review" in diff_response.text + assert "Approved Registry Impact" in diff_response.text + assert "Candidate Claims" in diff_response.text + assert "GET /ready" in diff_response.text + + change_approval = client.post( + f"{second_run_path}/changes/approve", + data={"notes": "Accept UI change review."}, + follow_redirects=False, + ) + assert change_approval.status_code == 303 + changed_detail = client.get(change_approval.headers["location"]) + assert "GET /ready" in changed_detail.text + assert "approve_analysis_run_changes" in changed_detail.text + search_response = client.get("/ui/search", params={"q": "repository"}) assert search_response.status_code == 200 assert "UI Repo" in search_response.text diff --git a/workplans/RREG-WP-0002-production-hardening.md b/workplans/RREG-WP-0002-production-hardening.md index 68b9c6b..99bd953 100644 --- a/workplans/RREG-WP-0002-production-hardening.md +++ b/workplans/RREG-WP-0002-production-hardening.md @@ -24,7 +24,7 @@ are reviewable; approved registry truth is explicit. ```task id: RREG-WP-0002-T01 -status: in_progress +status: done priority: high state_hub_task_id: "a27142a6-c160-4453-ab59-50a7db92f9c4" ```