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:
+11
-2
@@ -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
|
||||||
```
|
```
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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},
|
||||||
)
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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><!-- mcp-review-lease:v1 --></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 & 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)
|
||||||
Reference in New Issue
Block a user