generated from coulomb/repo-seed
web UI side of change review
This commit is contained in:
@@ -281,6 +281,7 @@ def repository_detail(
|
||||
<td><a href="/ui/repos/{repository_id}/analysis-runs/{run.id}">#{run.id}</a></td>
|
||||
<td><span class="pill">{escape(run.status)}</span></td>
|
||||
<td class="source">{escape(run.started_at)}</td>
|
||||
<td>{render_run_compare_link(repository_id, run.id, runs)}</td>
|
||||
<td>{escape(run.error_message or '')}</td>
|
||||
</tr>
|
||||
"""
|
||||
@@ -310,8 +311,8 @@ def repository_detail(
|
||||
</form>
|
||||
<h2>Analysis Runs</h2>
|
||||
<table>
|
||||
<thead><tr><th>Run</th><th>Status</th><th>Started</th><th>Error</th></tr></thead>
|
||||
<tbody>{run_rows or '<tr><td colspan="4" class="muted">No runs yet.</td></tr>'}</tbody>
|
||||
<thead><tr><th>Run</th><th>Status</th><th>Started</th><th>Compare</th><th>Error</th></tr></thead>
|
||||
<tbody>{run_rows or '<tr><td colspan="5" class="muted">No runs yet.</td></tr>'}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel">
|
||||
@@ -646,6 +647,7 @@ def analysis_run_detail(
|
||||
body = f"""
|
||||
<div class="actions">
|
||||
<h1 style="margin-right:auto">{escape(repository.name)} · Run #{analysis_run_id}</h1>
|
||||
{render_run_detail_compare_link(repository_id, analysis_run_id, service.list_analysis_runs(repository_id))}
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
@@ -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"""
|
||||
<div class="actions">
|
||||
<h1 style="margin-right:auto">{escape(diff.repository.name)} · Change Review</h1>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}/analysis-runs/{target_analysis_run_id}">Target Run</a>
|
||||
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
||||
</div>
|
||||
<p class="muted">Comparing run #{base_analysis_run_id} to run #{target_analysis_run_id}.</p>
|
||||
<section class="panel">
|
||||
<div class="actions">
|
||||
<h2 style="margin-right:auto">Approve Target Changes</h2>
|
||||
<form class="actions" method="post" action="/ui/repos/{repository_id}/analysis-runs/{target_analysis_run_id}/changes/approve">
|
||||
<input name="notes" value="Approved target run changes from web UI" aria-label="Approval notes">
|
||||
<button type="submit">Approve Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<div class="grid" style="margin-top:18px">
|
||||
<section class="panel">
|
||||
<h2>Approved Registry Impact</h2>
|
||||
{render_diff_section(asdict(diff.approved_entries))}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Candidate Claims</h2>
|
||||
{render_diff_section(asdict(diff.candidates))}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Observed Facts</h2>
|
||||
{render_diff_section(asdict(diff.facts))}
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>Content Chunks</h2>
|
||||
{render_diff_section(asdict(diff.chunks))}
|
||||
</section>
|
||||
</div>
|
||||
"""
|
||||
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 '<span class="muted">Baseline</span>'
|
||||
href = f"/ui/repos/{repository_id}/analysis-runs/{base_run.id}/diff/{run_id}"
|
||||
return f'<a href="{href}">Compare to #{base_run.id}</a>'
|
||||
|
||||
|
||||
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'<a class="button secondary" href="{href}">Compare to #{base_run.id}</a>'
|
||||
|
||||
|
||||
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 '<p class="muted">No differences.</p>'
|
||||
|
||||
|
||||
def render_diff_group(items: list[dict], title: str) -> str:
|
||||
if not items:
|
||||
return ""
|
||||
rows = "\n".join(
|
||||
f"""
|
||||
<tr>
|
||||
<td><span class="pill">{escape(item['item_type'])}</span></td>
|
||||
<td class="source">{escape(item['key'])}</td>
|
||||
<td>{render_diff_payload(item.get('base'))}</td>
|
||||
<td>{render_diff_payload(item.get('target'))}</td>
|
||||
</tr>
|
||||
"""
|
||||
for item in items
|
||||
)
|
||||
return f"""
|
||||
<h3>{escape(title)}</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Key</th><th>Base</th><th>Target</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def render_diff_payload(payload: dict | None) -> str:
|
||||
if payload is None:
|
||||
return '<span class="muted">None</span>'
|
||||
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'<div><span class="muted">{escape(key)}</span>: {escape(value)}</div>'
|
||||
)
|
||||
return "".join(parts) or '<span class="muted">No display fields.</span>'
|
||||
|
||||
|
||||
def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str:
|
||||
abilities = graph.get("abilities", [])
|
||||
if not abilities:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user