diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 0a5b935..96225b6 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -46,7 +46,8 @@ Optional environment variables: | `/runtime` | Stub — MCP runtime health (#430) | | `/audit` | Stub — report audit paste (#431) | | `/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 `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 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 +(``, #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 ```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 ``` \ No newline at end of file diff --git a/tests/test_webui_lease_visibility.py b/tests/test_webui_lease_visibility.py new file mode 100644 index 0000000..a515311 --- /dev/null +++ b/tests/test_webui_lease_visibility.py @@ -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 = ( + "\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() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 5dd23a1..953b368 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -46,10 +46,14 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertEqual(response.status_code, 200) 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): - for path in ("/worktrees", "/leases"): - with self.subTest(path=path): - self.assertEqual(self.client.get(path).status_code, 200) + self.assertEqual(self.client.get("/worktrees").status_code, 200) def test_post_is_rejected(self): response = self.client.post("/health") @@ -62,10 +66,10 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Live queue", response.text) 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): 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) diff --git a/webui/app.py b/webui/app.py index a97d1ad..4f4cb0f 100644 --- a/webui/app.py +++ b/webui/app.py @@ -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.prompt_library import find_prompt, library_to_dict 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_views import render_queue_page @@ -141,10 +143,12 @@ async def worktrees(_request: Request) -> HTMLResponse: async def leases(_request: Request) -> HTMLResponse: - return _stub_page( - "Leases", - "Lease visibility will show active issue and reviewer PR leases.", - ) + snapshot = load_lease_snapshot() + return HTMLResponse(render_leases_page(snapshot)) + + +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: @@ -175,6 +179,7 @@ def create_app() -> Starlette: Route("/audit", audit, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]), Route("/leases", leases, methods=["GET"]), + Route("/api/leases", api_leases, methods=["GET"]), ], exception_handlers={405: method_not_allowed}, ) \ No newline at end of file diff --git a/webui/lease_loader.py b/webui/lease_loader.py new file mode 100644 index 0000000..717feef --- /dev/null +++ b/webui/lease_loader.py @@ -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 = "" +_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, + } \ No newline at end of file diff --git a/webui/lease_views.py b/webui/lease_views.py new file mode 100644 index 0000000..a998dea --- /dev/null +++ b/webui/lease_views.py @@ -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 "
No duplicate PR/branch collisions detected in current inventory.
" + items = "".join(f"No active local issue lock file.
" + return ( + ""
+ f"{_escape(json.dumps(lock, indent=2, sort_keys=True))}"
+ ""
+ )
+
+
+def _claims_table(snapshot: LeaseSnapshot) -> str:
+ entries = snapshot.claim_inventory.get("entries") or []
+ if not entries:
+ return "No in-progress issue claims in fetched inventory.
" + rows = [] + for entry in entries: + rows.append( + "| Issue | Claim status | Open PR | " + "Heartbeats | Branches | Notes |
|---|
No active reviewer PR lease comments found "
+ "(marker <!-- mcp-review-lease:v1 -->; see #407).
{_escape(str(lease.get('profile') or '—'))}{_escape(str(lease.get('expires_at') or '—'))}| PR | Reviewer | Profile | " + "Phase | Expires |
|---|
Partial load: ' + f"{_escape(snapshot.fetch_error)}
Read-only visibility for issue claims, reviewer PR leases, and " + "duplicate-work risks. Does not acquire or release leases.
" + f"{error_block}" + f"" + "