fix: resolve conflicts for PR #385
Merge prgs/master into feat/issue-139-role-aware-work-issue-routing and keep both work-issue task routing entries and reconcile-landed-pr entries in task_capability_map.py.
This commit is contained in:
+353
-1
@@ -21,6 +21,7 @@ import json
|
||||
import functools
|
||||
import contextlib
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
# Mutation-authority record (#199, refs #194). Deliberately in-process, NOT a
|
||||
@@ -503,6 +504,7 @@ import already_landed_reconcile # noqa: E402
|
||||
import author_mutation_worktree # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
import merged_cleanup_reconcile # noqa: E402
|
||||
import reconciliation_workflow # noqa: E402
|
||||
import review_merge_state_machine # noqa: E402
|
||||
import native_mcp_preference # noqa: E402
|
||||
|
||||
@@ -510,6 +512,132 @@ import native_mcp_preference # noqa: E402
|
||||
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
|
||||
# consumed by gitea_create_pr and scripts/worktree-start.
|
||||
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
||||
WORK_LEASE_TTL_HOURS = 4
|
||||
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
||||
VALID_WORK_LEASE_OPERATIONS = frozenset({
|
||||
AUTHOR_ISSUE_WORK_LEASE,
|
||||
"review_pr_work",
|
||||
"fix_pr_changes",
|
||||
"cleanup_branch_work",
|
||||
"issue_filing_work",
|
||||
"recovery_work",
|
||||
})
|
||||
|
||||
|
||||
def _work_lease_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _work_lease_timestamp(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _parse_work_lease_timestamp(value: str | None) -> datetime | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _load_existing_issue_lock() -> dict | None:
|
||||
if not os.path.exists(ISSUE_LOCK_FILE):
|
||||
return None
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _work_lease_claimant(host: str | None) -> dict:
|
||||
profile = get_profile()
|
||||
username = _IDENTITY_CACHE.get(host) if host else None
|
||||
if username is None and host and not _preflight_in_test_mode():
|
||||
username = _authenticated_username(host)
|
||||
return {
|
||||
"username": username,
|
||||
"profile": profile.get("profile_name"),
|
||||
}
|
||||
|
||||
|
||||
def _build_author_issue_work_lease(
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
host: str | None,
|
||||
) -> dict:
|
||||
created = _work_lease_now()
|
||||
expires = created + timedelta(hours=WORK_LEASE_TTL_HOURS)
|
||||
return {
|
||||
"operation_type": AUTHOR_ISSUE_WORK_LEASE,
|
||||
"issue_number": issue_number,
|
||||
"pr_number": None,
|
||||
"branch": branch_name,
|
||||
"worktree_path": worktree_path,
|
||||
"claimant": _work_lease_claimant(host),
|
||||
"created_at": _work_lease_timestamp(created),
|
||||
"expires_at": _work_lease_timestamp(expires),
|
||||
"last_heartbeat_at": _work_lease_timestamp(created),
|
||||
}
|
||||
|
||||
|
||||
def _active_work_lease_block(
|
||||
existing_lock: dict | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
operation_type: str,
|
||||
) -> str | None:
|
||||
if not existing_lock:
|
||||
return None
|
||||
existing_issue = existing_lock.get("issue_number")
|
||||
existing_branch = existing_lock.get("branch_name")
|
||||
existing_worktree = existing_lock.get("worktree_path")
|
||||
lease = existing_lock.get("work_lease")
|
||||
existing_operation = (
|
||||
lease.get("operation_type")
|
||||
if isinstance(lease, dict)
|
||||
else AUTHOR_ISSUE_WORK_LEASE
|
||||
)
|
||||
if existing_issue != issue_number or existing_operation != operation_type:
|
||||
return None
|
||||
|
||||
same_owner = (
|
||||
existing_branch == branch_name
|
||||
and os.path.realpath(str(existing_worktree or "")) == os.path.realpath(worktree_path)
|
||||
)
|
||||
expires_at = (
|
||||
_parse_work_lease_timestamp(lease.get("expires_at"))
|
||||
if isinstance(lease, dict)
|
||||
else None
|
||||
)
|
||||
if expires_at and expires_at <= _work_lease_now():
|
||||
return (
|
||||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
||||
"Recovery review is required before takeover (fail closed)"
|
||||
)
|
||||
if same_owner:
|
||||
return None
|
||||
return (
|
||||
f"Issue #{issue_number} already has an active {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}' "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def _branch_entry_name(branch: dict | str) -> str:
|
||||
if isinstance(branch, str):
|
||||
return branch
|
||||
if not isinstance(branch, dict):
|
||||
return ""
|
||||
return str(branch.get("name") or branch.get("ref") or "")
|
||||
|
||||
|
||||
def _reveal_endpoints() -> bool:
|
||||
@@ -957,6 +1085,16 @@ def gitea_lock_issue(
|
||||
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
||||
worktree_path, PROJECT_ROOT
|
||||
)
|
||||
active_lease_block = _active_work_lease_block(
|
||||
_load_existing_issue_lock(),
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
operation_type=AUTHOR_ISSUE_WORK_LEASE,
|
||||
)
|
||||
if active_lease_block:
|
||||
raise RuntimeError(active_lease_block)
|
||||
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
|
||||
verify_preflight_purity(remote, worktree_path=resolved_worktree)
|
||||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||
@@ -1002,6 +1140,25 @@ def gitea_lock_issue(
|
||||
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (fail closed)"
|
||||
)
|
||||
|
||||
branch_url = f"{repo_api_url(h, o, r)}/branches"
|
||||
try:
|
||||
branches = api_get_all(branch_url, auth)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not list branches to verify issue lock: {e}")
|
||||
for branch in branches:
|
||||
name = _branch_entry_name(branch)
|
||||
if expected_pattern in name:
|
||||
raise ValueError(
|
||||
f"Issue #{issue_number} already has matching branch '{name}' "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
work_lease = _build_author_issue_work_lease(
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
host=h,
|
||||
)
|
||||
data = {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
@@ -1009,6 +1166,7 @@ def gitea_lock_issue(
|
||||
"org": o,
|
||||
"repo": r,
|
||||
"worktree_path": resolved_worktree,
|
||||
"work_lease": work_lease,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1029,6 +1187,7 @@ def gitea_lock_issue(
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": resolved_worktree,
|
||||
"work_lease": work_lease,
|
||||
}
|
||||
if agent_artifacts:
|
||||
result["warnings"] = [
|
||||
@@ -3453,6 +3612,178 @@ def gitea_reconcile_merged_cleanups(
|
||||
return {"success": True, "performed": True, **report}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_already_landed_reconciliation(
|
||||
pr_number: int,
|
||||
target_branch: str = "master",
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: assess already-landed reconciliation for one open PR (#301).
|
||||
|
||||
Fetches the PR live, refreshes the target branch, checks ancestor proof,
|
||||
and returns a capability plan for the active profile. Does not review,
|
||||
approve, request changes, merge, or mutate Gitea state.
|
||||
|
||||
Args:
|
||||
pr_number: Open PR to assess.
|
||||
target_branch: Branch used for ancestry proof (default master).
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
|
||||
Returns:
|
||||
dict with eligibility proof, capability plan, and safe next action.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
pr = api_request("GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth)
|
||||
|
||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||||
pr=pr,
|
||||
project_root=PROJECT_ROOT,
|
||||
remote=remote,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
profile = get_profile()
|
||||
capabilities = reconciliation_workflow.profile_reconciliation_capabilities(
|
||||
profile.get("allowed_operations"),
|
||||
profile.get("forbidden_operations"),
|
||||
)
|
||||
plan = reconciliation_workflow.resolve_reconciliation_plan(
|
||||
assessment=assessment,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"performed": False,
|
||||
"pr_number": pr_number,
|
||||
"assessment": assessment,
|
||||
"capabilities": capabilities,
|
||||
"plan": plan,
|
||||
"workflow_source": "skills/llm-project-workflow/workflows/reconcile-landed-pr.md",
|
||||
"task_mode": "reconcile-landed-pr",
|
||||
"review_merge_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_scan_already_landed_open_prs(
|
||||
target_branch: str = "master",
|
||||
limit: int = 50,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: list open PRs already landed on the target branch (#301).
|
||||
|
||||
Traverses open PR inventory with pagination proof, assesses each candidate,
|
||||
and returns PRs classified as ``ALREADY_LANDED_RECONCILE_REQUIRED``. Does
|
||||
not review, approve, merge, or mutate Gitea state.
|
||||
|
||||
Args:
|
||||
target_branch: Branch used for ancestry proof (default master).
|
||||
limit: Max open PRs to inspect across pages.
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
|
||||
Returns:
|
||||
dict with candidates, pagination metadata, and target branch SHA proof.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
|
||||
|
||||
open_prs: list[dict] = []
|
||||
current_page = 1
|
||||
pages_fetched = 0
|
||||
last_meta: dict = {}
|
||||
per_page = min(50, max(1, int(limit)))
|
||||
while pages_fetched < 100 and len(open_prs) < limit:
|
||||
raw_page, last_meta = api_fetch_page(
|
||||
url, auth, page=current_page, limit=per_page
|
||||
)
|
||||
pages_fetched += 1
|
||||
remaining = limit - len(open_prs)
|
||||
open_prs.extend(raw_page[:remaining])
|
||||
if last_meta["is_final_page"] or len(open_prs) >= limit:
|
||||
break
|
||||
current_page += 1
|
||||
|
||||
pagination = {
|
||||
"page": 1,
|
||||
"per_page": per_page,
|
||||
"returned_count": len(open_prs),
|
||||
"has_more": False,
|
||||
"next_page": None,
|
||||
"is_final_page": bool(last_meta.get("is_final_page")),
|
||||
"pages_fetched": pages_fetched,
|
||||
"inventory_complete": bool(last_meta.get("is_final_page")),
|
||||
"total_count": len(open_prs) if last_meta.get("is_final_page") else None,
|
||||
}
|
||||
|
||||
target_fetch = reconciliation_workflow.fetch_target_branch(
|
||||
PROJECT_ROOT, remote, target_branch
|
||||
)
|
||||
|
||||
candidates: list[dict] = []
|
||||
for pr in open_prs:
|
||||
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
|
||||
pr=pr,
|
||||
project_root=PROJECT_ROOT,
|
||||
remote=remote,
|
||||
target_branch=target_branch,
|
||||
target_fetch=target_fetch,
|
||||
)
|
||||
if assessment.get("eligibility_class") == (
|
||||
reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED
|
||||
):
|
||||
candidates.append(assessment)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"performed": False,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": target_fetch.get("target_branch_sha"),
|
||||
"git_ref_mutations": (
|
||||
[target_fetch["git_fetch_command"]]
|
||||
if target_fetch.get("git_fetch_command")
|
||||
else []
|
||||
),
|
||||
"candidates": candidates,
|
||||
"candidate_count": len(candidates),
|
||||
"pagination": pagination,
|
||||
"task_mode": "reconcile-landed-pr",
|
||||
"review_merge_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_reconcile_already_landed_pr(
|
||||
pr_number: int,
|
||||
@@ -3632,7 +3963,6 @@ def gitea_reconcile_already_landed_pr(
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_close_issue(
|
||||
issue_number: int,
|
||||
@@ -4415,6 +4745,28 @@ _PROJECT_SKILLS = {
|
||||
"Do not merge unless the operator explicitly authorizes it.",
|
||||
],
|
||||
},
|
||||
"gitea-reconcile-landed-pr": {
|
||||
"description": "Reconcile open PRs whose heads are already on the "
|
||||
"target branch without review or merge.",
|
||||
"when_to_use": "An open PR blocks the review queue but its head SHA "
|
||||
"is already an ancestor of master; use the dedicated "
|
||||
"reconciliation path instead of review/merge.",
|
||||
"required_operations": ["gitea.read"],
|
||||
"status": "available",
|
||||
"notes": "Load skills/llm-project-workflow/workflows/reconcile-landed-pr.md "
|
||||
"first. PR close requires exact gitea.pr.close; review/merge "
|
||||
"capabilities must not be used on this path.",
|
||||
"steps": [
|
||||
"Resolve task: gitea_resolve_task_capability(task='reconcile_landed_pr').",
|
||||
"Load canonical workflow via mcp_get_skill_guide or "
|
||||
"skills/llm-project-workflow/workflows/reconcile-landed-pr.md.",
|
||||
"Scan queue: gitea_scan_already_landed_open_prs (pagination proof).",
|
||||
"Assess one PR: gitea_assess_already_landed_reconciliation.",
|
||||
"Follow the plan outcome: full close only with gitea.pr.close, "
|
||||
"comment-then-stop when close is missing, recovery handoff otherwise.",
|
||||
"Never approve, request changes, or merge on this path.",
|
||||
],
|
||||
},
|
||||
"gitea-pr-merge": {
|
||||
"description": "Gated merge of an approved pull request.",
|
||||
"when_to_use": "Reviewer/merger profile with explicit operator "
|
||||
|
||||
Reference in New Issue
Block a user