Merge pull request 'feat: add lease and collision visibility to web UI (Closes #433)' (#454) from feat/issue-433-lease-visibility into master

This commit was merged in pull request #454.
This commit is contained in:
2026-07-08 21:22:21 -05:00
6 changed files with 583 additions and 11 deletions
+11 -2
View File
@@ -46,7 +46,8 @@ Optional environment variables:
| `/runtime` | Stub — MCP runtime health (#430) | | `/runtime` | Stub — MCP runtime health (#430) |
| `/audit` | Stub — report audit paste (#431) | | `/audit` | Stub — report audit paste (#431) |
| `/worktrees` | Stub — hygiene dashboard (#432) | | `/worktrees` | Stub — hygiene dashboard (#432) |
| `/leases` | Stub — lease visibility (#433) | | `/leases` | Lease and collision visibility (#433) |
| `/api/leases` | JSON lease/collision export |
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`. `read-only-mvp`.
@@ -82,8 +83,16 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched,
If credentials are missing or the fetch fails, the page shows an explicit error If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed). instead of an empty queue (fail closed).
## Lease visibility (#433)
`/leases` surfaces read-only lease and collision state: local issue lock file,
in-progress claim inventory (#268), reviewer PR lease comments when present
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
and duplicate local branches per issue. Links to collision-history backend
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
## Tests ## Tests
```bash ```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py -q
``` ```
+94
View File
@@ -0,0 +1,94 @@
"""Tests for web UI lease visibility (#433)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.lease_loader import (
_duplicate_branch_warnings,
_duplicate_pr_warnings,
load_lease_snapshot,
parse_reviewer_lease_comment,
snapshot_to_dict,
)
class TestLeaseLoader(unittest.TestCase):
def test_parse_reviewer_lease_comment(self):
body = (
"<!-- mcp-review-lease:v1 -->\n"
"pr: #42\n"
"reviewer_identity: sysadmin\n"
"profile: prgs-reviewer\n"
"phase: validating\n"
"expires_at: 2026-07-07T20:00:00Z\n"
)
parsed = parse_reviewer_lease_comment(body)
self.assertIsNotNone(parsed)
assert parsed is not None
self.assertEqual(parsed["pr_number"], 42)
self.assertEqual(parsed["phase"], "validating")
def test_duplicate_pr_warnings(self):
raw_prs = [
{"number": 1, "title": "Closes #99", "body": ""},
{"number": 2, "title": "fixes #99", "body": ""},
]
warnings = _duplicate_pr_warnings(raw_prs)
self.assertEqual(len(warnings), 1)
self.assertEqual(warnings[0].issue_number, 99)
self.assertEqual(warnings[0].pr_numbers, (1, 2))
def test_duplicate_branch_warnings(self):
warnings = _duplicate_branch_warnings([
"feat/issue-12-a",
"feat/issue-12-b",
"master",
])
self.assertEqual(len(warnings), 1)
self.assertEqual(warnings[0].issue_number, 12)
def test_load_snapshot_with_injected_fetch(self):
def fetch_prs(_h, _o, _r, _a):
return ([], None)
def fetch_issues(_h, _o, _r, _a):
return ([], None)
snapshot = load_lease_snapshot(
fetch_prs=fetch_prs,
fetch_issues=fetch_issues,
fetch_comments=lambda *_a, **_k: [],
issue_lock_path=str(Path("/nonexistent/lock.json")),
)
data = snapshot_to_dict(snapshot)
self.assertIn("claim_inventory", data)
self.assertIn("collision_history", data)
class TestLeaseRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_leases_page_renders(self):
response = self.client.get("/leases")
self.assertEqual(response.status_code, 200)
self.assertIn("Leases", response.text)
self.assertIn("Collision warnings", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_api_leases_json(self):
response = self.client.get("/api/leases")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("claim_inventory", data)
self.assertIn("duplicate_prs", data)
self.assertIn("reviewer_leases", data)
if __name__ == "__main__":
unittest.main()
+9 -5
View File
@@ -46,10 +46,14 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn("Gitea-Tools", response.text) self.assertIn("Gitea-Tools", response.text)
def test_leases_is_implemented(self):
response = self.client.get("/leases")
self.assertEqual(response.status_code, 200)
self.assertIn("Leases", response.text)
self.assertIn("Collision warnings", response.text)
def test_extra_stub_routes(self): def test_extra_stub_routes(self):
for path in ("/worktrees", "/leases"): self.assertEqual(self.client.get("/worktrees").status_code, 200)
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 200)
def test_post_is_rejected(self): def test_post_is_rejected(self):
response = self.client.post("/health") response = self.client.post("/health")
@@ -62,10 +66,10 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Live queue", response.text) self.assertIn("Live queue", response.text)
def test_nav_links_on_all_pages(self): def test_nav_links_on_all_pages(self):
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"): for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/leases"):
with self.subTest(path=path): with self.subTest(path=path):
text = self.client.get(path).text text = self.client.get(path).text
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"): for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/leases"):
self.assertIn(f'href="{href}"', text) self.assertIn(f'href="{href}"', text)
+9 -4
View File
@@ -14,6 +14,8 @@ from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list from webui.project_views import render_project_detail, render_projects_list
from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page from webui.prompt_views import render_prompt_detail, render_prompts_page
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
from webui.lease_views import render_leases_page
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict from webui.queue_loader import load_queue_snapshot, snapshot_to_dict
from webui.queue_views import render_queue_page from webui.queue_views import render_queue_page
@@ -141,10 +143,12 @@ async def worktrees(_request: Request) -> HTMLResponse:
async def leases(_request: Request) -> HTMLResponse: async def leases(_request: Request) -> HTMLResponse:
return _stub_page( snapshot = load_lease_snapshot()
"Leases", return HTMLResponse(render_leases_page(snapshot))
"Lease visibility will show active issue and reviewer PR leases.",
)
async def api_leases(_request: Request) -> JSONResponse:
return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot()))
async def method_not_allowed(request: Request, _exc: Exception) -> Response: async def method_not_allowed(request: Request, _exc: Exception) -> Response:
@@ -175,6 +179,7 @@ def create_app() -> Starlette:
Route("/audit", audit, methods=["GET"]), Route("/audit", audit, methods=["GET"]),
Route("/worktrees", worktrees, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]),
Route("/leases", leases, methods=["GET"]), Route("/leases", leases, methods=["GET"]),
Route("/api/leases", api_leases, methods=["GET"]),
], ],
exception_handlers={405: method_not_allowed}, exception_handlers={405: method_not_allowed},
) )
+330
View File
@@ -0,0 +1,330 @@
"""Lease and collision visibility for the web UI (#433)."""
from __future__ import annotations
import os
import re
import subprocess
from dataclasses import dataclass
from typing import Any, Callable
from urllib.parse import urlparse
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
from issue_claim_heartbeat import build_claim_inventory
from merged_cleanup_reconcile import ISSUE_LOCK_FILE, read_issue_lock
from webui.project_registry import ProjectRecord, load_registry
from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs
_REVIEWER_LEASE_MARKER = "<!-- mcp-review-lease:v1 -->"
_REVIEWER_FIELD_RE = re.compile(
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_ISSUE_BRANCH_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
_COLLISION_HISTORY = (
{"number": 267, "title": "Author work leases"},
{"number": 268, "title": "Issue claim heartbeat leases"},
{"number": 400, "title": "Early duplicate-work detection"},
{"number": 407, "title": "Per-PR reviewer leases"},
)
@dataclass(frozen=True)
class CollisionWarning:
kind: str
message: str
issue_number: int | None = None
pr_numbers: tuple[int, ...] = ()
@dataclass(frozen=True)
class LeaseSnapshot:
project_id: str
repo_label: str
issue_lock: dict[str, Any] | None
claim_inventory: dict[str, Any]
reviewer_leases: tuple[dict[str, Any], ...]
duplicate_prs: tuple[CollisionWarning, ...]
duplicate_branches: tuple[CollisionWarning, ...]
collision_history: tuple[dict[str, Any], ...]
fetch_error: str | None = None
def _repo_root() -> str:
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
if override:
return os.path.realpath(override)
return os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))
def _host_from_url(remote_host: str) -> str:
parsed = urlparse(remote_host.strip())
return parsed.netloc or remote_host.strip().rstrip("/")
def _parse_pr_ref(value: str | None) -> int | None:
digits = re.sub(r"[^\d]", "", value or "")
return int(digits) if digits.isdigit() else None
def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None:
text = body or ""
if _REVIEWER_LEASE_MARKER not in text:
return None
fields: dict[str, str] = {}
for match in _REVIEWER_FIELD_RE.finditer(text):
fields[match.group(1).strip().lower()] = match.group(2).strip()
if not fields:
return None
return {
"pr_number": _parse_pr_ref(fields.get("pr")),
"issue_number": _parse_pr_ref(fields.get("issue")),
"reviewer_identity": fields.get("reviewer_identity"),
"profile": fields.get("profile"),
"phase": (fields.get("phase") or "").strip().lower() or None,
"expires_at": fields.get("expires_at"),
"blocker": fields.get("blocker"),
}
def _fetch_comments(
host: str,
org: str,
repo: str,
auth: str,
*,
issue_number: int,
) -> list[dict]:
url = f"{repo_api_url(host, org, repo)}/issues/{issue_number}/comments"
comments: list[dict] = []
page = 1
while page <= 10:
raw_page, meta = api_fetch_page(url, auth, page=page, limit=50)
comments.extend(raw_page)
if meta.get("is_final_page"):
break
page += 1
return comments
def _list_local_branch_names(project_root: str) -> list[str]:
result = subprocess.run(
["git", "-C", project_root, "branch", "--list", "--format=%(refname:short)"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return []
return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
def _duplicate_pr_warnings(raw_prs: list[dict]) -> list[CollisionWarning]:
issue_to_prs: dict[int, list[int]] = {}
for pr in raw_prs:
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
if linked is None:
continue
issue_to_prs.setdefault(linked, []).append(int(pr["number"]))
warnings: list[CollisionWarning] = []
for issue_number, pr_numbers in sorted(issue_to_prs.items()):
if len(pr_numbers) > 1:
warnings.append(
CollisionWarning(
kind="duplicate-pr",
issue_number=issue_number,
pr_numbers=tuple(sorted(pr_numbers)),
message=(
f"Issue #{issue_number} has {len(pr_numbers)} open PRs: "
f"{', '.join(f'#{n}' for n in sorted(pr_numbers))} (#400)"
),
)
)
return warnings
def _duplicate_branch_warnings(branch_names: list[str]) -> list[CollisionWarning]:
issue_to_branches: dict[int, list[str]] = {}
for name in branch_names:
match = _ISSUE_BRANCH_RE.search(name)
if not match:
continue
issue_num = int(match.group(1))
issue_to_branches.setdefault(issue_num, []).append(name)
warnings: list[CollisionWarning] = []
for issue_number, branches in sorted(issue_to_branches.items()):
if len(branches) > 1:
warnings.append(
CollisionWarning(
kind="duplicate-branch",
issue_number=issue_number,
message=(
f"Issue #{issue_number} has {len(branches)} local branches: "
f"{', '.join(branches)}"
),
)
)
return warnings
def _extract_reviewer_leases(
raw_prs: list[dict],
*,
fetch_comments: Callable[[int], list[dict]],
) -> list[dict[str, Any]]:
leases: list[dict[str, Any]] = []
for pr in raw_prs:
pr_number = int(pr["number"])
for comment in fetch_comments(pr_number):
parsed = parse_reviewer_lease_comment(comment.get("body") or "")
if not parsed:
continue
leases.append(
{
**parsed,
"pr_number": parsed.get("pr_number") or pr_number,
"comment_id": comment.get("id"),
"author": (comment.get("user") or {}).get("login"),
"created_at": comment.get("created_at"),
}
)
active_phases = {"claimed", "validating", "approved", "request-changes", "merging"}
return [lease for lease in leases if (lease.get("phase") or "") in active_phases]
def load_lease_snapshot(
*,
project_id: str | None = None,
project_root: str | None = None,
issue_lock_path: str | None = None,
fetch_prs: Callable | None = None,
fetch_issues: Callable | None = None,
fetch_comments: Callable[[str, str, str, str, int], list[dict]] | None = None,
) -> LeaseSnapshot:
registry = load_registry()
project: ProjectRecord | None = None
if project_id:
project = next((p for p in registry.projects if p.id == project_id), None)
else:
project = registry.projects[0] if registry.projects else None
root = project_root or _repo_root()
lock = read_issue_lock(issue_lock_path if issue_lock_path is not None else ISSUE_LOCK_FILE)
if project is None:
return LeaseSnapshot(
project_id=project_id or "",
repo_label="",
issue_lock=lock,
claim_inventory={"entries": [], "counts": {}},
reviewer_leases=(),
duplicate_prs=(),
duplicate_branches=(),
collision_history=_COLLISION_HISTORY,
fetch_error="project not found in registry",
)
host = _host_from_url(project.remote_host)
pr_fetch = fetch_prs or _fetch_prs
issue_fetch = fetch_issues or _fetch_issues
comment_fetch = fetch_comments or _fetch_comments
using_live = fetch_prs is None or fetch_issues is None
auth = get_auth_header(host) if using_live else "test-auth"
if using_live and not auth:
return LeaseSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
issue_lock=lock,
claim_inventory={"entries": [], "counts": {}},
reviewer_leases=(),
duplicate_prs=(),
duplicate_branches=_duplicate_branch_warnings(_list_local_branch_names(root)),
collision_history=_COLLISION_HISTORY,
fetch_error=(
f"Gitea credentials unavailable for {host}; "
"remote lease artifacts cannot be loaded"
),
)
try:
raw_prs, _ = pr_fetch(host, project.gitea_owner, project.repo_name, auth)
raw_issues, _ = issue_fetch(host, project.gitea_owner, project.repo_name, auth)
except Exception as exc: # noqa: BLE001
return LeaseSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
issue_lock=lock,
claim_inventory={"entries": [], "counts": {}},
reviewer_leases=(),
duplicate_prs=(),
duplicate_branches=_duplicate_branch_warnings(_list_local_branch_names(root)),
collision_history=_COLLISION_HISTORY,
fetch_error=f"Gitea fetch failed: {exc}",
)
comments_by_issue: dict[int, list[dict]] = {}
for issue in raw_issues:
if not any(lb.get("name") == "status:in-progress" for lb in issue.get("labels", [])):
continue
number = int(issue["number"])
comments_by_issue[number] = comment_fetch(
host, project.gitea_owner, project.repo_name, auth, issue_number=number
)
branch_names = _list_local_branch_names(root)
inventory = build_claim_inventory(
issues=raw_issues,
comments_by_issue=comments_by_issue,
open_prs=raw_prs,
branch_names=branch_names,
)
def _pr_comments(pr_number: int) -> list[dict]:
return comment_fetch(
host, project.gitea_owner, project.repo_name, auth, issue_number=pr_number
)
reviewer_leases = tuple(_extract_reviewer_leases(raw_prs, fetch_comments=_pr_comments))
return LeaseSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
issue_lock=lock,
claim_inventory=inventory,
reviewer_leases=reviewer_leases,
duplicate_prs=tuple(_duplicate_pr_warnings(raw_prs)),
duplicate_branches=tuple(_duplicate_branch_warnings(branch_names)),
collision_history=_COLLISION_HISTORY,
)
def snapshot_to_dict(snapshot: LeaseSnapshot) -> dict[str, Any]:
return {
"project_id": snapshot.project_id,
"repo_label": snapshot.repo_label,
"issue_lock": snapshot.issue_lock,
"claim_inventory": snapshot.claim_inventory,
"reviewer_leases": list(snapshot.reviewer_leases),
"duplicate_prs": [
{
"kind": w.kind,
"message": w.message,
"issue_number": w.issue_number,
"pr_numbers": list(w.pr_numbers),
}
for w in snapshot.duplicate_prs
],
"duplicate_branches": [
{
"kind": w.kind,
"message": w.message,
"issue_number": w.issue_number,
}
for w in snapshot.duplicate_branches
],
"collision_history": list(snapshot.collision_history),
"fetch_error": snapshot.fetch_error,
}
+130
View File
@@ -0,0 +1,130 @@
"""HTML views for lease and collision visibility (#433)."""
from __future__ import annotations
import html
import json
from webui.layout import render_page
from webui.lease_loader import LeaseSnapshot
def _escape(text: str) -> str:
return html.escape(text, quote=True)
def _warnings_block(snapshot: LeaseSnapshot) -> str:
warnings = list(snapshot.duplicate_prs) + list(snapshot.duplicate_branches)
if not warnings:
return "<p class='muted'>No duplicate PR/branch collisions detected in current inventory.</p>"
items = "".join(f"<li>{_escape(w.message)}</li>" for w in warnings)
return f"<ul class='collision-warnings'>{items}</ul>"
def _lock_block(snapshot: LeaseSnapshot) -> str:
lock = snapshot.issue_lock
if not lock:
return "<p class='muted'>No active local issue lock file.</p>"
return (
"<pre class='prompt-text'>"
f"{_escape(json.dumps(lock, indent=2, sort_keys=True))}"
"</pre>"
)
def _claims_table(snapshot: LeaseSnapshot) -> str:
entries = snapshot.claim_inventory.get("entries") or []
if not entries:
return "<p class='muted'>No in-progress issue claims in fetched inventory.</p>"
rows = []
for entry in entries:
rows.append(
"<tr>"
f"<td>#{entry.get('issue_number')}</td>"
f"<td><span class='badge badge-{_escape(str(entry.get('status')))}'>"
f"{_escape(str(entry.get('status')))}</span></td>"
f"<td>{_escape(str(entry.get('linked_open_pr') or ''))}</td>"
f"<td>{entry.get('heartbeat_count', 0)}</td>"
f"<td>{_escape(', '.join(entry.get('matching_branches') or []) or '')}</td>"
f"<td class='muted'>{_escape('; '.join(entry.get('reasons') or []))}</td>"
"</tr>"
)
return (
"<table class='registry leases'>"
"<thead><tr><th>Issue</th><th>Claim status</th><th>Open PR</th>"
"<th>Heartbeats</th><th>Branches</th><th>Notes</th></tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
def _reviewer_leases_table(snapshot: LeaseSnapshot) -> str:
if not snapshot.reviewer_leases:
return (
"<p class='muted'>No active reviewer PR lease comments found "
"(marker <code>&lt;!-- mcp-review-lease:v1 --&gt;</code>; see #407).</p>"
)
rows = []
for lease in snapshot.reviewer_leases:
rows.append(
"<tr>"
f"<td>#{lease.get('pr_number')}</td>"
f"<td>{_escape(str(lease.get('reviewer_identity') or ''))}</td>"
f"<td><code>{_escape(str(lease.get('profile') or ''))}</code></td>"
f"<td>{_escape(str(lease.get('phase') or ''))}</td>"
f"<td><code>{_escape(str(lease.get('expires_at') or ''))}</code></td>"
"</tr>"
)
return (
"<table class='registry leases'>"
"<thead><tr><th>PR</th><th>Reviewer</th><th>Profile</th>"
"<th>Phase</th><th>Expires</th></tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
def _history_links(snapshot: LeaseSnapshot) -> str:
items = "".join(
f"<li><a href=\"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/"
f"{item['number']}\">#{item['number']}</a> — {_escape(item['title'])}</li>"
for item in snapshot.collision_history
)
return f"<ul>{items}</ul>"
LEASE_PAGE_STYLES = """
<style>
.collision-warnings li { color: #f0c4c4; margin: 0.35rem 0; }
table.leases td:nth-child(6) { font-size: 0.85rem; }
.badge-active, .badge-awaiting_review { color: #8fd19e; border-color: #3d6b4a; }
.badge-stale, .badge-phantom, .badge-reclaimable { color: #f0a8a8; border-color: #7a3b3b; }
</style>
"""
def render_leases_page(snapshot: LeaseSnapshot) -> str:
error_block = ""
if snapshot.fetch_error:
error_block = (
'<div class="stub"><p><strong>Partial load:</strong> '
f"{_escape(snapshot.fetch_error)}</p></div>"
)
body = (
"<h2>Leases &amp; collisions</h2>"
"<p>Read-only visibility for issue claims, reviewer PR leases, and "
"duplicate-work risks. Does not acquire or release leases.</p>"
f"{error_block}"
f"<p class='meta'>Project: <code>{_escape(snapshot.repo_label)}</code></p>"
"<h3>Collision warnings</h3>"
f"{_warnings_block(snapshot)}"
"<h3>Local issue lock</h3>"
f"{_lock_block(snapshot)}"
"<h3>In-progress issue claims (#268)</h3>"
f"{_claims_table(snapshot)}"
"<h3>Reviewer PR leases (#407)</h3>"
f"{_reviewer_leases_table(snapshot)}"
"<h3>Collision / lease history issues</h3>"
f"{_history_links(snapshot)}"
"<p><a href=\"/api/leases\">JSON API</a></p>"
f"{LEASE_PAGE_STYLES}"
)
return render_page(title="Leases", body_html=body)