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><a href="/ui/repos/{repository_id}/analysis-runs/{run.id}">#{run.id}</a></td>
|
||||||
<td><span class="pill">{escape(run.status)}</span></td>
|
<td><span class="pill">{escape(run.status)}</span></td>
|
||||||
<td class="source">{escape(run.started_at)}</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>
|
<td>{escape(run.error_message or '')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
"""
|
"""
|
||||||
@@ -310,8 +311,8 @@ def repository_detail(
|
|||||||
</form>
|
</form>
|
||||||
<h2>Analysis Runs</h2>
|
<h2>Analysis Runs</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Run</th><th>Status</th><th>Started</th><th>Error</th></tr></thead>
|
<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="4" class="muted">No runs yet.</td></tr>'}</tbody>
|
<tbody>{run_rows or '<tr><td colspan="5" class="muted">No runs yet.</td></tr>'}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -646,6 +647,7 @@ def analysis_run_detail(
|
|||||||
body = f"""
|
body = f"""
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<h1 style="margin-right:auto">{escape(repository.name)} · Run #{analysis_run_id}</h1>
|
<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>
|
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -676,6 +678,59 @@ def analysis_run_detail(
|
|||||||
return page(f"{repository.name} Run {analysis_run_id}", body)
|
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")
|
@router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve")
|
||||||
def approve_candidate_graph_from_form(
|
def approve_candidate_graph_from_form(
|
||||||
repository_id: int,
|
repository_id: int,
|
||||||
@@ -690,6 +745,21 @@ def approve_candidate_graph_from_form(
|
|||||||
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
|
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(
|
@router.post(
|
||||||
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
|
||||||
"/candidate-abilities/{candidate_ability_id}/reject"
|
"/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:
|
def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str:
|
||||||
abilities = graph.get("abilities", [])
|
abilities = graph.get("abilities", [])
|
||||||
if not abilities:
|
if not abilities:
|
||||||
|
|||||||
@@ -841,6 +841,7 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
|
|||||||
)
|
)
|
||||||
assert run_response.status_code == 303
|
assert run_response.status_code == 303
|
||||||
run_path = run_response.headers["location"]
|
run_path = run_response.headers["location"]
|
||||||
|
first_run_id = int(run_path.rsplit("/", 1)[-1])
|
||||||
|
|
||||||
run_detail = client.get(run_path)
|
run_detail = client.get(run_path)
|
||||||
assert run_detail.status_code == 200
|
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 "interface:app.py:3" in approved_detail.text
|
||||||
assert "approve_candidate_graph" 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"})
|
search_response = client.get("/ui/search", params={"q": "repository"})
|
||||||
assert search_response.status_code == 200
|
assert search_response.status_code == 200
|
||||||
assert "UI Repo" in search_response.text
|
assert "UI Repo" in search_response.text
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ are reviewable; approved registry truth is explicit.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RREG-WP-0002-T01
|
id: RREG-WP-0002-T01
|
||||||
status: in_progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a27142a6-c160-4453-ab59-50a7db92f9c4"
|
state_hub_task_id: "a27142a6-c160-4453-ab59-50a7db92f9c4"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user