+9
-4
@@ -16,6 +16,8 @@ from webui.prompt_library import find_prompt, library_to_dict
|
||||
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
||||
from webui.gated_actions import attempt_action, load_action_registry, preview_action
|
||||
from webui.gated_action_views import render_actions_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
|
||||
|
||||
@@ -144,10 +146,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 actions(_request: Request) -> HTMLResponse:
|
||||
@@ -225,6 +229,7 @@ def create_app() -> Starlette:
|
||||
api_action_attempt,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route("/api/leases", api_leases, methods=["GET"]),
|
||||
],
|
||||
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)
|
||||
@@ -38,7 +38,13 @@ def _queue_table(
|
||||
title: str,
|
||||
items: tuple[QueueItem, ...],
|
||||
columns: tuple[tuple[str, str], ...],
|
||||
fetch_failed: bool = False,
|
||||
) -> str:
|
||||
if fetch_failed:
|
||||
return (
|
||||
f"<h3>{html.escape(title)}</h3>"
|
||||
"<p class='muted'>Not loaded — queue fetch did not complete.</p>"
|
||||
)
|
||||
if not items:
|
||||
return f"<h3>{html.escape(title)}</h3><p class='muted'>No open items.</p>"
|
||||
|
||||
@@ -73,9 +79,11 @@ def render_queue_page(snapshot: QueueSnapshot) -> str:
|
||||
f"{html.escape(snapshot.fetch_error)}</p></div>"
|
||||
)
|
||||
|
||||
fetch_failed = bool(snapshot.fetch_error)
|
||||
pr_section = _queue_table(
|
||||
title="Open pull requests",
|
||||
items=snapshot.prs,
|
||||
fetch_failed=fetch_failed,
|
||||
columns=(
|
||||
("number", "#"),
|
||||
("title", "Title"),
|
||||
@@ -88,6 +96,7 @@ def render_queue_page(snapshot: QueueSnapshot) -> str:
|
||||
issue_section = _queue_table(
|
||||
title="Open issues",
|
||||
items=snapshot.issues,
|
||||
fetch_failed=fetch_failed,
|
||||
columns=(
|
||||
("number", "#"),
|
||||
("title", "Title"),
|
||||
|
||||
Reference in New Issue
Block a user