"""Issue claim heartbeat leases and stale-claim reconciliation (#268). Structured issue-thread comments prove live ownership beyond the ``status:in-progress`` label alone. Queue inventory can classify claims as active, stale, reclaimable, PR-backed, or phantom. """ from __future__ import annotations import re from datetime import datetime, timedelta, timezone from typing import Any MARKER = "" IN_PROGRESS_LABEL = "status:in-progress" _KIND_CLAIM = "claim" _KIND_PROGRESS = "progress" _KIND_CLEANUP = "cleanup" _FIELD_RE = re.compile( r"^\s*-\s*([a-z_]+)\s*:\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE, ) _ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE) def _parse_timestamp(value: str | None) -> datetime | None: if not value: return None text = value.strip() if text.endswith("Z"): text = text[:-1] + "+00:00" try: parsed = datetime.fromisoformat(text) except ValueError: return None if parsed.tzinfo is None: return parsed.replace(tzinfo=timezone.utc) return parsed def format_heartbeat_body( *, kind: str, issue_number: int, branch: str, phase: str, profile: str | None = None, pr: str = "none", next_action: str = "none", blocker: str = "none", ) -> str: """Return a structured, machine-parseable issue comment body.""" profile_value = (profile or "unknown").strip() or "unknown" lines = [ MARKER, "**Issue claim heartbeat**", f"- kind: {kind}", f"- issue: #{issue_number}", f"- branch: {branch}", f"- phase: {phase}", f"- profile: {profile_value}", f"- pr: {pr}", f"- blocker: {blocker}", f"- next_action: {next_action}", ] return "\n".join(lines) def parse_heartbeat_comment(body: str) -> dict[str, Any] | None: """Parse one structured heartbeat comment, or None when not a heartbeat.""" text = body or "" if MARKER not in text: return None fields: dict[str, str] = {} for match in _FIELD_RE.finditer(text): fields[match.group(1).strip().lower()] = match.group(2).strip() if not fields: return None issue_raw = fields.get("issue", "") issue_digits = re.sub(r"[^\d]", "", issue_raw) issue_number = int(issue_digits) if issue_digits.isdigit() else None return { "kind": fields.get("kind"), "issue_number": issue_number, "branch": fields.get("branch"), "phase": fields.get("phase"), "profile": fields.get("profile"), "pr": fields.get("pr"), "blocker": fields.get("blocker"), "next_action": fields.get("next_action"), "raw_fields": fields, } def extract_issue_heartbeats( comments: list[dict], *, issue_number: int | None = None, ) -> list[dict]: """Return parsed heartbeats newest-last, optionally filtered to *issue_number*.""" heartbeats: list[dict] = [] for comment in comments or []: parsed = parse_heartbeat_comment(comment.get("body") or "") if not parsed: continue if issue_number is not None and parsed.get("issue_number") != issue_number: continue heartbeats.append( { **parsed, "comment_id": comment.get("id"), "author": (comment.get("user") or {}).get("login") or comment.get("author"), "created_at": comment.get("created_at"), "updated_at": comment.get("updated_at"), } ) return heartbeats def issue_has_in_progress_label(issue: dict) -> bool: labels = issue.get("labels") or [] for label in labels: name = label if isinstance(label, str) else label.get("name") if name == IN_PROGRESS_LABEL: return True return False def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None: pattern = f"issue-{issue_number}" closes = f"closes #{issue_number}" fixes = f"fixes #{issue_number}" for pr in open_prs or []: head = (pr.get("head") or {}).get("ref") or "" text = f"{pr.get('title', '')} {pr.get('body', '')}".lower() if pattern in head.lower(): return pr if closes in text or fixes in text: return pr return None def _matching_branch_names(issue_number: int, branch_names: list[str]) -> list[str]: pattern = f"issue-{issue_number}" return [name for name in branch_names if pattern in (name or "").lower()] def classify_issue_claim( *, issue: dict, comments: list[dict], open_prs: list[dict] | None = None, branch_names: list[str] | None = None, now: datetime | None = None, heartbeat_lease_minutes: int = 30, reclaim_after_minutes: int = 60, ) -> dict[str, Any]: """Classify one issue's claim state from labels, heartbeats, PRs, and branches.""" issue_number = int(issue.get("number") or 0) open_prs = open_prs or [] branch_names = branch_names or [] current = now or datetime.now(timezone.utc) has_label = issue_has_in_progress_label(issue) heartbeats = extract_issue_heartbeats(comments, issue_number=issue_number) latest = heartbeats[-1] if heartbeats else None latest_at = _parse_timestamp( (latest or {}).get("updated_at") or (latest or {}).get("created_at") ) age_minutes = None if latest_at: age_minutes = int((current - latest_at).total_seconds() // 60) linked_pr = _linked_open_pr(issue_number, open_prs) matching_branches = _matching_branch_names(issue_number, branch_names) if not has_label: status = "not_claimed" reasons = ["issue lacks status:in-progress label"] elif linked_pr: status = "awaiting_review" reasons = [f"open PR #{linked_pr.get('number')} covers this issue"] elif not heartbeats: status = "phantom" reasons = [ "status:in-progress label present without structured claim heartbeat" ] elif latest_at is None: status = "phantom" reasons = ["heartbeat comments present but timestamps could not be parsed"] elif age_minutes is not None and age_minutes <= heartbeat_lease_minutes: status = "active" reasons = [f"heartbeat age {age_minutes}m within {heartbeat_lease_minutes}m lease"] elif matching_branches and age_minutes is not None and age_minutes <= reclaim_after_minutes: status = "active" reasons = [ f"matching branch(es) {', '.join(matching_branches)} with heartbeat " f"age {age_minutes}m" ] elif age_minutes is not None and age_minutes > reclaim_after_minutes and not matching_branches: status = "reclaimable" reasons = [ f"no heartbeat within {reclaim_after_minutes}m and no matching branch" ] elif age_minutes is not None and age_minutes > heartbeat_lease_minutes: status = "stale" reasons = [ f"heartbeat age {age_minutes}m exceeds {heartbeat_lease_minutes}m lease" ] else: status = "active" reasons = ["claim has structured heartbeat proof"] return { "issue_number": issue_number, "title": issue.get("title"), "status": status, "has_in_progress_label": has_label, "heartbeat_count": len(heartbeats), "latest_heartbeat": latest, "heartbeat_age_minutes": age_minutes, "linked_open_pr": linked_pr.get("number") if linked_pr else None, "matching_branches": matching_branches, "reasons": reasons, "reclaimable": status == "reclaimable", "stale": status in {"stale", "phantom", "reclaimable"}, } def build_claim_inventory( *, issues: list[dict], comments_by_issue: dict[int, list[dict]], open_prs: list[dict], branch_names: list[str], now: datetime | None = None, heartbeat_lease_minutes: int = 30, reclaim_after_minutes: int = 60, ) -> dict[str, Any]: """Build a queue inventory report for in-progress issue claims.""" entries: list[dict] = [] for issue in issues or []: if not issue_has_in_progress_label(issue): continue number = int(issue["number"]) entry = classify_issue_claim( issue=issue, comments=comments_by_issue.get(number, []), open_prs=open_prs, branch_names=branch_names, now=now, heartbeat_lease_minutes=heartbeat_lease_minutes, reclaim_after_minutes=reclaim_after_minutes, ) entries.append(entry) counts: dict[str, int] = {} for entry in entries: counts[entry["status"]] = counts.get(entry["status"], 0) + 1 return { "entries": entries, "counts": counts, "heartbeat_lease_minutes": heartbeat_lease_minutes, "reclaim_after_minutes": reclaim_after_minutes, "in_progress_total": len(entries), } def build_cleanup_plan(inventory: dict[str, Any]) -> list[dict]: """Return stale/reclaimable/phantom claims that may be cleaned up.""" plan: list[dict] = [] for entry in inventory.get("entries") or []: if entry.get("status") in {"reclaimable", "phantom"}: plan.append( { "issue_number": entry["issue_number"], "status": entry["status"], "action": "remove_status_in_progress_and_comment", "reasons": list(entry.get("reasons") or []), } ) elif entry.get("status") == "stale" and not entry.get("linked_open_pr"): plan.append( { "issue_number": entry["issue_number"], "status": entry["status"], "action": "report_only", "reasons": list(entry.get("reasons") or []), } ) return plan