Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44a7c62dc3 | ||
|
|
d5dc9f2708 | ||
|
|
cc4741a4ce | ||
|
|
d95ea323d7 | ||
|
|
e4adccd82a | ||
|
|
795f544047 | ||
|
|
0bad26230b | ||
|
|
cf057d7829 |
@@ -274,26 +274,27 @@ is proven abandoned and the takeover is recorded.
|
||||
|
||||
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
|
||||
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
|
||||
records an `author_issue_work` lease in a keyed lock file under
|
||||
`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file
|
||||
per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds
|
||||
its active lock through a per-process pointer so concurrent repos/issues never
|
||||
share one overwrite-prone slot (#443).
|
||||
records an `author_issue_work` lease in the issue-lock payload with issue
|
||||
number, optional PR number, branch, worktree path, claimant identity/profile,
|
||||
created timestamp, expiry timestamp, and last heartbeat timestamp. An active
|
||||
same-issue/same-operation lease blocks duplicate work. An expired lease still
|
||||
blocks takeover until a recovery review records why the prior work is abandoned,
|
||||
completed, or unsafe to continue.
|
||||
|
||||
Each lock payload includes issue number, optional PR number, branch, worktree
|
||||
path, claimant identity/profile, created timestamp, expiry timestamp, and last
|
||||
heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
|
||||
work. An expired lease still blocks takeover until a recovery review records why
|
||||
the prior work is abandoned, completed, or unsafe to continue.
|
||||
**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
|
||||
`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
|
||||
shared state and manual writes can clobber another session's live lease. Use
|
||||
`sanctioned recovery` instead:
|
||||
|
||||
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
|
||||
recovery path.** That global slot is deprecated and can clobber unrelated live
|
||||
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
|
||||
adoption rebinds the session when the issue's exact branch already exists (#442).
|
||||
`gitea_create_pr` resolves the durable keyed lock by session pointer or by
|
||||
matching `head` branch without unsafe manual seeding (#440). Branch ownership
|
||||
uses structured ``(fix|feat|docs|chore)/issue-<n>-`` parsing — not broad
|
||||
substring matches like ``issue-420`` inside ``issue-4200``.
|
||||
1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
|
||||
2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
|
||||
3. Operator override only when explicitly authorized — record
|
||||
`External-state mutations` and `operator override proof` in the final report.
|
||||
|
||||
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
|
||||
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
|
||||
under `External-state mutations: none` or mix author PR creation with reviewer
|
||||
approval in one run. See also #438 (global lock redesign).
|
||||
|
||||
Remote branches matching the issue number are also treated as active work unless
|
||||
the recovery review proves the branch is abandoned or superseded. Never delete
|
||||
|
||||
@@ -11,6 +11,8 @@ import inspect
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
import issue_lock_provenance
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -870,6 +872,54 @@ def _rule_reviewer_mutation_ledger(
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_issue_lock_external_state(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_issue_lock_external_state_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.issue_lock_external_state",
|
||||
result.get("reasons") or [],
|
||||
field="External-state mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"disclose gitea_issue_lock.json read/write/delete under "
|
||||
"External-state mutations; never claim none after lock seeding"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_manual_lock_pr_without_override(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.manual_lock_pr_override",
|
||||
result.get("reasons") or [],
|
||||
field="External-state mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"use gitea_lock_issue or #442 adoption instead of manual lock seeding; "
|
||||
"if operator override was authorized, cite override proof"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_author_reviewer_same_run_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.author_reviewer_same_run",
|
||||
result.get("reasons") or [],
|
||||
field="Review mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"split author PR creation and reviewer approval across separate "
|
||||
"sessions and handoffs"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_review_mutation(
|
||||
report_text: str,
|
||||
*,
|
||||
@@ -889,10 +939,31 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_post_merge_cleanup_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.post_merge_cleanup_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Cleanup status",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
|
||||
)
|
||||
|
||||
|
||||
_SHARED_ISSUE_LOCK_RULES = (
|
||||
_rule_shared_issue_lock_external_state,
|
||||
_rule_shared_manual_lock_pr_override,
|
||||
_rule_shared_author_reviewer_same_run,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_reviewer_mutation_categories,
|
||||
@@ -909,10 +980,12 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_target_branch_freshness,
|
||||
_rule_reviewer_mutation_ledger,
|
||||
_rule_reviewer_review_mutation,
|
||||
_rule_reviewer_post_merge_cleanup_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
_rule_reconcile_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
_rule_reconcile_eligible_reviewed,
|
||||
_rule_reconcile_linked_issue_live,
|
||||
@@ -924,25 +997,30 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"author_issue": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
"work_issue": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
"issue_filing": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
"issue_selection": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
+232
-140
@@ -537,13 +537,12 @@ import role_namespace_gate # noqa: E402
|
||||
import task_capability_map # noqa: E402
|
||||
import review_proofs # noqa: E402
|
||||
import agent_temp_artifacts
|
||||
import issue_branch_ownership # noqa: E402
|
||||
import issue_lock_worktree # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import already_landed_reconcile # noqa: E402
|
||||
import author_mutation_worktree # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
import merged_cleanup_reconcile # noqa: E402
|
||||
import reconciler_profile # noqa: E402
|
||||
import reconciliation_workflow # noqa: E402
|
||||
@@ -551,9 +550,8 @@ import review_merge_state_machine # noqa: E402
|
||||
import native_mcp_preference # noqa: E402
|
||||
|
||||
|
||||
# Keyed issue-lock storage (#443): per remote/org/repo/issue files under
|
||||
# GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer.
|
||||
# Legacy global path retained only for test/doc references — do not seed manually.
|
||||
# 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"
|
||||
@@ -585,59 +583,15 @@ def _parse_work_lease_timestamp(value: str | None) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _load_existing_issue_lock(
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
) -> dict | None:
|
||||
if remote and org and repo and issue_number is not None:
|
||||
return issue_lock_store.load_issue_lock(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
)
|
||||
return issue_lock_store.read_session_issue_lock()
|
||||
|
||||
|
||||
def _resolve_issue_lock_for_pr(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
head: str,
|
||||
) -> dict:
|
||||
lock_data = issue_lock_store.read_session_issue_lock()
|
||||
if not lock_data:
|
||||
lock_data = issue_lock_store.find_lock_for_branch(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
branch_name=head,
|
||||
)
|
||||
if not lock_data:
|
||||
raise RuntimeError(
|
||||
"Issue lock is missing (fail closed). Call gitea_lock_issue first."
|
||||
)
|
||||
return lock_data
|
||||
|
||||
|
||||
def _save_issue_lock(data: dict) -> str:
|
||||
existing = issue_lock_store.load_issue_lock(
|
||||
remote=str(data.get("remote") or ""),
|
||||
org=str(data.get("org") or ""),
|
||||
repo=str(data.get("repo") or ""),
|
||||
issue_number=int(data.get("issue_number") or 0),
|
||||
)
|
||||
overwrite_block = issue_lock_store.assess_foreign_lock_overwrite(existing, data)
|
||||
if overwrite_block:
|
||||
raise RuntimeError(overwrite_block)
|
||||
def _load_existing_issue_lock() -> dict | None:
|
||||
if not os.path.exists(ISSUE_LOCK_FILE):
|
||||
return None
|
||||
try:
|
||||
return issue_lock_store.bind_session_lock(data)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not write issue lock file: {e}") from e
|
||||
with open(ISSUE_LOCK_FILE, "r", 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:
|
||||
@@ -727,17 +681,117 @@ def _branch_entry_name(branch: dict | str) -> str:
|
||||
return str(branch.get("name") or branch.get("ref") or "")
|
||||
|
||||
|
||||
def _branch_entry_commit_sha(branch: dict | str) -> str | None:
|
||||
"""Best-effort head SHA for a Gitea branch entry (None when absent)."""
|
||||
if not isinstance(branch, dict):
|
||||
def _live_fetch_issue_duplicate_context(
|
||||
h: str,
|
||||
o: str,
|
||||
r: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
) -> tuple[list[dict], list[str], dict]:
|
||||
"""Live open PRs, remote branch names, and claim state for one issue."""
|
||||
base = repo_api_url(h, o, r)
|
||||
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
|
||||
branches = api_get_all(f"{base}/branches", auth)
|
||||
branch_names = [_branch_entry_name(b) for b in branches]
|
||||
issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {}
|
||||
comments = api_request(
|
||||
"GET", f"{base}/issues/{issue_number}/comments", auth
|
||||
) or []
|
||||
claim_entry = issue_claim_heartbeat.classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=comments,
|
||||
open_prs=open_prs,
|
||||
branch_names=branch_names,
|
||||
)
|
||||
return open_prs, branch_names, claim_entry
|
||||
|
||||
|
||||
# Injectable duplicate-work context fetcher (#400). Production uses the live
|
||||
# Gitea API path above; unit tests patch this symbol instead of hitting the
|
||||
# network.
|
||||
issue_duplicate_context_fetcher = _live_fetch_issue_duplicate_context
|
||||
|
||||
|
||||
def _collect_issue_duplicate_context(
|
||||
h: str,
|
||||
o: str,
|
||||
r: str,
|
||||
auth: str,
|
||||
issue_number: int,
|
||||
) -> tuple[list[dict], list[str], dict]:
|
||||
return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
|
||||
|
||||
|
||||
def _assess_issue_duplicate_gate(
|
||||
issue_number: int,
|
||||
*,
|
||||
h: str,
|
||||
o: str,
|
||||
r: str,
|
||||
auth: str,
|
||||
locked_branch: str | None = None,
|
||||
phase: str,
|
||||
) -> dict:
|
||||
open_prs, branch_names, claim_entry = _collect_issue_duplicate_context(
|
||||
h, o, r, auth, issue_number
|
||||
)
|
||||
return issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||||
issue_number,
|
||||
open_prs=open_prs,
|
||||
branch_names=branch_names,
|
||||
claim_entry=claim_entry,
|
||||
locked_branch=locked_branch,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
|
||||
def _duplicate_gate_block_response(gate: dict, **extra) -> dict:
|
||||
out = {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": list(gate.get("reasons") or []),
|
||||
"duplicate_gate": gate,
|
||||
"safe_next_action": gate.get("safe_next_action"),
|
||||
}
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
def _enforce_locked_issue_duplicate_recheck(
|
||||
remote: str,
|
||||
phase: str,
|
||||
*,
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Re-check duplicate-work gates for the locked issue (#400)."""
|
||||
lock_data = _load_existing_issue_lock()
|
||||
if not lock_data:
|
||||
return None
|
||||
commit = branch.get("commit")
|
||||
if isinstance(commit, dict):
|
||||
sha = commit.get("id") or commit.get("sha")
|
||||
if sha:
|
||||
return str(sha)
|
||||
sha = branch.get("commit_sha")
|
||||
return str(sha) if sha else None
|
||||
issue_number = int(lock_data.get("issue_number") or 0)
|
||||
locked_branch = lock_data.get("branch_name")
|
||||
if not issue_number:
|
||||
return None
|
||||
h, o, r = _resolve(
|
||||
remote or lock_data.get("remote") or "dadeschools",
|
||||
host or lock_data.get("host"),
|
||||
org or lock_data.get("org"),
|
||||
repo or lock_data.get("repo"),
|
||||
)
|
||||
auth = _auth(h)
|
||||
gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
h=h,
|
||||
o=o,
|
||||
r=r,
|
||||
auth=auth,
|
||||
locked_branch=locked_branch,
|
||||
phase=phase,
|
||||
)
|
||||
if gate.get("block"):
|
||||
return gate
|
||||
return None
|
||||
|
||||
|
||||
def _reveal_endpoints() -> bool:
|
||||
@@ -1187,9 +1241,8 @@ def gitea_lock_issue(
|
||||
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
|
||||
worktree_path, PROJECT_ROOT
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
_load_existing_issue_lock(remote=remote, org=o, repo=r, issue_number=issue_number),
|
||||
active_lease_block = _active_work_lease_block(
|
||||
_load_existing_issue_lock(),
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
@@ -1213,58 +1266,21 @@ def gitea_lock_issue(
|
||||
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
|
||||
)
|
||||
|
||||
# 2. Check if the issue already has an open PR (reuse protection)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
|
||||
|
||||
try:
|
||||
prs = api_get_all(url, auth)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not list open PRs to verify issue lock: {e}")
|
||||
|
||||
for pr in prs:
|
||||
pr_head = pr.get("head", {}).get("ref", "")
|
||||
pr_title = pr.get("title", "")
|
||||
pr_body = pr.get("body", "")
|
||||
|
||||
if issue_branch_ownership.branch_tracks_issue(pr_head, issue_number):
|
||||
raise ValueError(
|
||||
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}, branch '{pr_head}') (fail closed)"
|
||||
)
|
||||
|
||||
patterns = [
|
||||
f"closes #{issue_number}",
|
||||
f"fixes #{issue_number}",
|
||||
]
|
||||
text_to_check = f"{pr_title} {pr_body}".lower()
|
||||
if any(p in text_to_check for p in patterns):
|
||||
raise ValueError(
|
||||
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}")
|
||||
existing_branch_entries = [
|
||||
{
|
||||
"name": _branch_entry_name(branch),
|
||||
"commit_sha": _branch_entry_commit_sha(branch),
|
||||
}
|
||||
for branch in branches
|
||||
]
|
||||
adoption = issue_lock_adoption.assess_own_branch_adoption(
|
||||
issue_number=issue_number,
|
||||
requested_branch=branch_name,
|
||||
existing_branches=existing_branch_entries,
|
||||
duplicate_gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
h=h,
|
||||
o=o,
|
||||
r=r,
|
||||
auth=auth,
|
||||
locked_branch=branch_name,
|
||||
phase=issue_work_duplicate_gate.PHASE_LOCK,
|
||||
)
|
||||
if adoption["block"]:
|
||||
competing = ", ".join(adoption["competing_branches"])
|
||||
raise ValueError(
|
||||
f"Issue #{issue_number} already has matching branch '{competing}' "
|
||||
"that is not the requested branch (fail closed)"
|
||||
)
|
||||
if duplicate_gate.get("block"):
|
||||
raise ValueError("; ".join(duplicate_gate.get("reasons") or [
|
||||
f"duplicate work gate blocked issue #{issue_number} (fail closed)"
|
||||
]))
|
||||
|
||||
work_lease = _build_author_issue_work_lease(
|
||||
issue_number=issue_number,
|
||||
@@ -1280,9 +1296,17 @@ def gitea_lock_issue(
|
||||
"repo": r,
|
||||
"worktree_path": resolved_worktree,
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease.get("claimant"),
|
||||
),
|
||||
}
|
||||
|
||||
lock_file_path = _save_issue_lock(data)
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not write issue lock file: {e}")
|
||||
|
||||
agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
|
||||
git_state.get("porcelain_status") or ""
|
||||
@@ -1297,22 +1321,7 @@ def gitea_lock_issue(
|
||||
"branch_name": branch_name,
|
||||
"worktree_path": resolved_worktree,
|
||||
"work_lease": work_lease,
|
||||
"lock_file_path": lock_file_path,
|
||||
}
|
||||
if adoption["adopt"]:
|
||||
result["adoption"] = issue_lock_adoption.build_adoption_proof(
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
assessment=adoption,
|
||||
open_pr_checked=True,
|
||||
competing_lock_checked=True,
|
||||
lock_file_path=lock_file_path,
|
||||
lock_file_status="written",
|
||||
)
|
||||
result["message"] = (
|
||||
f"Adopted existing branch '{branch_name}' and locked issue "
|
||||
f"#{issue_number} for recovery (fail-closed check complete)."
|
||||
)
|
||||
if agent_artifacts:
|
||||
result["warnings"] = [
|
||||
"Agent temp artifacts at repo root (delete before implementation): "
|
||||
@@ -1321,6 +1330,39 @@ def gitea_lock_issue(
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_work_issue_duplicate(
|
||||
issue_number: int,
|
||||
branch_name: str | None = None,
|
||||
phase: str = issue_work_duplicate_gate.PHASE_LOCK,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only duplicate-work gate for author sessions before mutations (#400)."""
|
||||
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)
|
||||
gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
h=h,
|
||||
o=o,
|
||||
r=r,
|
||||
auth=auth,
|
||||
locked_branch=branch_name,
|
||||
phase=phase,
|
||||
)
|
||||
return {"success": not gate.get("block"), **gate}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_create_pr(
|
||||
title: str,
|
||||
@@ -1371,8 +1413,23 @@ def gitea_create_pr(
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
|
||||
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
|
||||
lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head)
|
||||
# ── Issue Lock Validation (Issue #194 / #196) ──
|
||||
if not os.path.exists(ISSUE_LOCK_FILE):
|
||||
raise RuntimeError("Issue lock is missing (fail closed). Call gitea_lock_issue first.")
|
||||
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
|
||||
lock_data = json.load(f)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)")
|
||||
|
||||
lock_provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr(
|
||||
lock_data
|
||||
)
|
||||
if lock_provenance_check["block"]:
|
||||
raise RuntimeError(
|
||||
issue_lock_provenance.format_lock_provenance_error(lock_provenance_check)
|
||||
)
|
||||
|
||||
locked_issue = lock_data.get("issue_number")
|
||||
locked_branch = lock_data.get("branch_name")
|
||||
@@ -1405,6 +1462,21 @@ def gitea_create_pr(
|
||||
f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
|
||||
)
|
||||
|
||||
duplicate_block = _enforce_locked_issue_duplicate_recheck(
|
||||
remote,
|
||||
issue_work_duplicate_gate.PHASE_CREATE_PR,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
if duplicate_block:
|
||||
return _duplicate_gate_block_response(
|
||||
duplicate_block,
|
||||
number=None,
|
||||
issue_number=locked_issue,
|
||||
branch_name=locked_branch,
|
||||
)
|
||||
|
||||
auth = _auth(h)
|
||||
url = f"{repo_api_url(h, o, r)}/pulls"
|
||||
payload = {"title": title, "body": body, "head": head, "base": base}
|
||||
@@ -2868,7 +2940,13 @@ def _prepare_commit_payload_files(files: list[dict]) -> tuple[list[dict], list[d
|
||||
processed_files = []
|
||||
source_proofs = []
|
||||
|
||||
lock_data = issue_lock_store.read_session_issue_lock() or {}
|
||||
lock_data = {}
|
||||
if os.path.exists(ISSUE_LOCK_FILE):
|
||||
try:
|
||||
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
|
||||
lock_data = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
locked_worktree = lock_data.get("worktree_path")
|
||||
if locked_worktree:
|
||||
@@ -3003,6 +3081,20 @@ def gitea_commit_files(
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
duplicate_block = _enforce_locked_issue_duplicate_recheck(
|
||||
remote,
|
||||
issue_work_duplicate_gate.PHASE_COMMIT,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
if duplicate_block:
|
||||
return _duplicate_gate_block_response(
|
||||
duplicate_block,
|
||||
commit="",
|
||||
branch="",
|
||||
)
|
||||
|
||||
verify_preflight_purity(remote)
|
||||
processed_files, source_proofs = _prepare_commit_payload_files(files)
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Structured issue/branch ownership evidence (#440).
|
||||
|
||||
Author branches must follow ``(fix|feat|docs|chore)/issue-<n>-<desc>``. Duplicate-work
|
||||
and recovery gates use this parser instead of broad substring checks like
|
||||
``issue-420`` inside unrelated names (for example ``issue-4200``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
IMPLEMENTATION_BRANCH_RE = re.compile(
|
||||
r"^(?:fix|feat|docs|chore)/issue-(\d+)(?:-.+)?$"
|
||||
)
|
||||
|
||||
|
||||
def parse_tracked_issue_number(branch_name: str) -> int | None:
|
||||
"""Return the issue number encoded in a canonical author branch name."""
|
||||
text = (branch_name or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
match = IMPLEMENTATION_BRANCH_RE.match(text)
|
||||
if not match:
|
||||
return None
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def branch_tracks_issue(branch_name: str, issue_number: int) -> bool:
|
||||
"""True when ``branch_name`` structurally belongs to ``issue_number``."""
|
||||
parsed = parse_tracked_issue_number(branch_name)
|
||||
return parsed == issue_number if parsed is not None else False
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
|
||||
|
||||
When an issue's own already-pushed branch exists, lock reacquisition must be
|
||||
allowed (adoption) instead of being treated as #400 duplicate competing work.
|
||||
This module isolates the pure decision so it can be unit-tested apart from the
|
||||
MCP server's live Gitea calls.
|
||||
|
||||
Adoption is granted only for the issue's *exact* requested branch. Any other
|
||||
branch that merely contains the same ``issue-<n>`` marker is competing work and
|
||||
stays fail-closed. Open-PR, competing-live-lock, capability, and worktree
|
||||
safety checks are enforced by the caller before this decision is consulted;
|
||||
this module additionally records whether they passed for proof purposes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import issue_branch_ownership
|
||||
|
||||
ADOPT = "adopt_existing_branch"
|
||||
BLOCK_COMPETING = "block_competing_branch"
|
||||
NO_MATCH = "no_matching_branch"
|
||||
|
||||
|
||||
def _branch_name(entry) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("name") or "")
|
||||
return str(entry or "")
|
||||
|
||||
|
||||
def _branch_sha(entry) -> str | None:
|
||||
if isinstance(entry, dict):
|
||||
sha = entry.get("commit_sha")
|
||||
if sha:
|
||||
return str(sha)
|
||||
return None
|
||||
|
||||
|
||||
def assess_own_branch_adoption(
|
||||
*,
|
||||
issue_number: int,
|
||||
requested_branch: str,
|
||||
existing_branches,
|
||||
) -> dict:
|
||||
"""Decide whether an existing matching branch is adoptable."""
|
||||
requested = (requested_branch or "").strip()
|
||||
|
||||
matches: list[tuple[str, str | None]] = []
|
||||
for entry in existing_branches or []:
|
||||
name = _branch_name(entry).strip()
|
||||
if issue_branch_ownership.branch_tracks_issue(name, issue_number):
|
||||
matches.append((name, _branch_sha(entry)))
|
||||
|
||||
competing = sorted({name for name, _ in matches if name != requested})
|
||||
exact = [(name, sha) for name, sha in matches if name == requested]
|
||||
|
||||
if competing:
|
||||
return {
|
||||
"outcome": BLOCK_COMPETING,
|
||||
"adopt": False,
|
||||
"block": True,
|
||||
"reason": (
|
||||
f"issue #{issue_number} already has matching branch(es) "
|
||||
f"{competing} that are not the requested branch "
|
||||
f"'{requested}' (fail closed)"
|
||||
),
|
||||
"matched_branch": None,
|
||||
"matched_head_sha": None,
|
||||
"competing_branches": competing,
|
||||
}
|
||||
|
||||
if exact:
|
||||
name, sha = exact[0]
|
||||
return {
|
||||
"outcome": ADOPT,
|
||||
"adopt": True,
|
||||
"block": False,
|
||||
"reason": (
|
||||
f"existing branch '{name}' is the exact requested branch for "
|
||||
f"issue #{issue_number}; adopting it for lock recovery"
|
||||
),
|
||||
"matched_branch": name,
|
||||
"matched_head_sha": sha,
|
||||
"competing_branches": [],
|
||||
}
|
||||
|
||||
return {
|
||||
"outcome": NO_MATCH,
|
||||
"adopt": False,
|
||||
"block": False,
|
||||
"reason": f"no existing branch matches issue #{issue_number}",
|
||||
"matched_branch": None,
|
||||
"matched_head_sha": None,
|
||||
"competing_branches": [],
|
||||
}
|
||||
|
||||
|
||||
def build_adoption_proof(
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
assessment: dict,
|
||||
open_pr_checked: bool,
|
||||
competing_lock_checked: bool,
|
||||
lock_file_path: str,
|
||||
lock_file_status: str,
|
||||
) -> dict:
|
||||
"""Assemble the proof block returned by ``gitea_lock_issue`` on adoption."""
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"branch_head_commit": assessment.get("matched_head_sha"),
|
||||
"adoption_reason": assessment.get("reason"),
|
||||
"no_existing_pr_proof": bool(open_pr_checked),
|
||||
"no_competing_live_lock_proof": bool(competing_lock_checked),
|
||||
"lock_file_path": lock_file_path,
|
||||
"lock_file_status": lock_file_status,
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Issue-lock provenance and external-state disclosure (#447).
|
||||
|
||||
Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery
|
||||
#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be
|
||||
blocked at PR creation unless explicit operator override proof is recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
|
||||
|
||||
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
|
||||
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
|
||||
SOURCE_OPERATOR_OVERRIDE = "operator_override"
|
||||
|
||||
SANCTIONED_LOCK_SOURCES = frozenset({
|
||||
SOURCE_LOCK_ISSUE,
|
||||
SOURCE_LOCK_ADOPTION,
|
||||
SOURCE_OPERATOR_OVERRIDE,
|
||||
})
|
||||
|
||||
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
|
||||
|
||||
_ISSUE_LOCK_PATH_RE = re.compile(
|
||||
r"(?:/tmp/)?gitea_issue_lock\.json",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCK_SEED_RE = re.compile(
|
||||
r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|"
|
||||
r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_REMOVE_RE = re.compile(
|
||||
r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_READ_RE = re.compile(
|
||||
r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_EXTERNAL_NONE_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_FIELD_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_ONLY_RE = re.compile(
|
||||
r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_CREATED_RE = re.compile(
|
||||
r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|"
|
||||
r"PR\s+creation\s+(?:succeeded|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_APPROVE_RE = re.compile(
|
||||
r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|"
|
||||
r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OVERRIDE_PROOF_RE = re.compile(
|
||||
r"operator[- ]override\s+proof\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def build_sanctioned_lock_provenance(
|
||||
*,
|
||||
tool: str,
|
||||
source: str = SOURCE_LOCK_ISSUE,
|
||||
claimant: dict | None = None,
|
||||
adoption: dict | None = None,
|
||||
) -> dict:
|
||||
"""Return provenance metadata stored with a sanctioned lock write."""
|
||||
entry = {
|
||||
"source": source,
|
||||
"written_at": _utc_now_iso(),
|
||||
"written_by_tool": tool,
|
||||
"lock_file_path": ISSUE_LOCK_FILE,
|
||||
}
|
||||
if claimant:
|
||||
entry["claimant"] = claimant
|
||||
if adoption:
|
||||
entry["adoption"] = adoption
|
||||
return entry
|
||||
|
||||
|
||||
def operator_override_requested() -> bool:
|
||||
return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict:
|
||||
text = (reason or "").strip()
|
||||
if not text:
|
||||
raise ValueError(
|
||||
"operator override requires a non-empty override reason (fail closed)"
|
||||
)
|
||||
entry = build_sanctioned_lock_provenance(
|
||||
tool="operator_override",
|
||||
source=SOURCE_OPERATOR_OVERRIDE,
|
||||
claimant=claimant,
|
||||
)
|
||||
entry["override_reason"] = text
|
||||
return entry
|
||||
|
||||
|
||||
def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict:
|
||||
"""Fail closed when lock file lacks sanctioned provenance (#447)."""
|
||||
data = lock_data if isinstance(lock_data, dict) else {}
|
||||
reasons: list[str] = []
|
||||
provenance = data.get("lock_provenance")
|
||||
if not isinstance(provenance, dict):
|
||||
reasons.append(
|
||||
"issue lock file lacks sanctioned lock_provenance; manual seeding is "
|
||||
"not a normal recovery path — call gitea_lock_issue or use #442 adoption"
|
||||
)
|
||||
return _provenance_result(False, reasons, provenance)
|
||||
|
||||
source = str(provenance.get("source") or "").strip()
|
||||
if source not in SANCTIONED_LOCK_SOURCES:
|
||||
reasons.append(
|
||||
f"issue lock provenance source '{source or '(missing)'}' is not sanctioned"
|
||||
)
|
||||
|
||||
if source == SOURCE_OPERATOR_OVERRIDE and not str(
|
||||
provenance.get("override_reason") or ""
|
||||
).strip():
|
||||
reasons.append(
|
||||
"operator_override lock provenance requires override_reason proof"
|
||||
)
|
||||
|
||||
if not data.get("work_lease"):
|
||||
reasons.append("issue lock file missing work_lease metadata")
|
||||
|
||||
if not str(provenance.get("written_by_tool") or "").strip():
|
||||
reasons.append("issue lock provenance missing written_by_tool")
|
||||
|
||||
proven = not reasons
|
||||
return _provenance_result(proven, reasons, provenance)
|
||||
|
||||
|
||||
def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"lock_provenance": provenance,
|
||||
}
|
||||
|
||||
|
||||
def format_lock_provenance_error(assessment: dict) -> str:
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"])
|
||||
return f"Issue lock provenance guard (#447): {reasons} (fail closed)"
|
||||
|
||||
|
||||
def _lock_activity_detected(text: str) -> dict[str, bool]:
|
||||
body = text or ""
|
||||
return {
|
||||
"seed_or_restore": bool(_LOCK_SEED_RE.search(body)),
|
||||
"remove": bool(_LOCK_REMOVE_RE.search(body)),
|
||||
"read": bool(_LOCK_READ_RE.search(body)),
|
||||
}
|
||||
|
||||
|
||||
def _external_state_discloses_lock(text: str) -> bool:
|
||||
match = _EXTERNAL_FIELD_RE.search(text or "")
|
||||
if not match:
|
||||
return False
|
||||
value = (match.group(1) or "").strip().lower()
|
||||
if value in {"", "none", "n/a"}:
|
||||
return False
|
||||
return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value
|
||||
|
||||
|
||||
def assess_issue_lock_external_state_report(report_text: str) -> dict:
|
||||
"""Require explicit external-state disclosure for issue-lock mutations (#447)."""
|
||||
text = report_text or ""
|
||||
activity = _lock_activity_detected(text)
|
||||
if not any(activity.values()):
|
||||
return {"proven": True, "block": False, "reasons": [], "activity": activity}
|
||||
|
||||
reasons: list[str] = []
|
||||
disclosed = _external_state_discloses_lock(text)
|
||||
|
||||
if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions seeding/restoring gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif activity["seed_or_restore"] and not disclosed:
|
||||
reasons.append(
|
||||
"report mentions issue-lock file activity but External-state mutations "
|
||||
"does not disclose read/write of gitea_issue_lock.json"
|
||||
)
|
||||
|
||||
if activity["remove"]:
|
||||
if _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions removing gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif not disclosed and _CLEANUP_ONLY_RE.search(text):
|
||||
reasons.append(
|
||||
"report removes issue lock but classifies it as cleanup only; "
|
||||
"record under External-state mutations"
|
||||
)
|
||||
elif not disclosed:
|
||||
reasons.append(
|
||||
"report mentions deleting issue lock without External-state "
|
||||
"mutation disclosure"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"activity": activity,
|
||||
}
|
||||
|
||||
|
||||
def assess_manual_lock_pr_without_override(report_text: str) -> dict:
|
||||
"""Block reports that created a PR via manual lock seed without override proof."""
|
||||
text = report_text or ""
|
||||
seeded = bool(_LOCK_SEED_RE.search(text))
|
||||
created = bool(_PR_CREATED_RE.search(text))
|
||||
if not (seeded and created):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
if _OVERRIDE_PROOF_RE.search(text):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report created/opened a PR after manual issue-lock seeding without "
|
||||
"operator override proof"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def assess_author_reviewer_same_run_report(report_text: str) -> dict:
|
||||
"""Reviewer handoff must not create and approve the same PR in one run (#447)."""
|
||||
text = report_text or ""
|
||||
if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report mixes author-side PR creation and reviewer approval in one "
|
||||
"final handoff; split author and reviewer sessions"
|
||||
],
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
"""Keyed, persistent issue-lock storage (#443).
|
||||
|
||||
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
|
||||
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
|
||||
``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
|
||||
via a per-process pointer file so concurrent repos/issues never clobber each
|
||||
other.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
|
||||
DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
|
||||
WORK_LEASE_TTL_HOURS = 4
|
||||
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
|
||||
|
||||
def default_lock_dir() -> str:
|
||||
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
|
||||
return raw or DEFAULT_LOCK_DIR
|
||||
|
||||
|
||||
def _sanitize_segment(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "_"
|
||||
return _SAFE_SEGMENT_RE.sub("_", text)
|
||||
|
||||
|
||||
def lock_key(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
) -> str:
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (remote, org, repo, str(issue_number))
|
||||
)
|
||||
|
||||
|
||||
def lock_file_path(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
lock_dir: str | None = None,
|
||||
) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json")
|
||||
|
||||
|
||||
def session_pointer_path(lock_dir: str | None = None) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
return os.path.join(root, f"session-{os.getpid()}.json")
|
||||
|
||||
|
||||
def _ensure_lock_dir(lock_dir: str | None = None) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def read_lock_file(path: str) -> dict[str, Any] | None:
|
||||
lock_path = (path or "").strip()
|
||||
if not lock_path or not os.path.exists(lock_path):
|
||||
return None
|
||||
try:
|
||||
with open(lock_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
||||
lock_path = (path or "").strip()
|
||||
if not lock_path:
|
||||
raise ValueError("lock path is required (fail closed)")
|
||||
parent = os.path.dirname(lock_path) or "."
|
||||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, lock_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session."""
|
||||
remote = str(lock_data.get("remote") or "")
|
||||
org = str(lock_data.get("org") or "")
|
||||
repo = str(lock_data.get("repo") or "")
|
||||
issue_number = int(lock_data.get("issue_number") or 0)
|
||||
if not remote or not org or not repo or issue_number <= 0:
|
||||
raise ValueError("lock record must include remote, org, repo, and issue_number")
|
||||
|
||||
root = _ensure_lock_dir(lock_dir)
|
||||
path = lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
lock_dir=root,
|
||||
)
|
||||
record = dict(lock_data)
|
||||
record["lock_file_path"] = path
|
||||
record["session_pid"] = os.getpid()
|
||||
save_lock_file(path, record)
|
||||
|
||||
pointer = {
|
||||
"pid": os.getpid(),
|
||||
"lock_file_path": path,
|
||||
"issue_number": issue_number,
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
return path
|
||||
|
||||
|
||||
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
pointer = read_lock_file(session_pointer_path(root))
|
||||
if not pointer:
|
||||
return None
|
||||
lock_path = str(pointer.get("lock_file_path") or "").strip()
|
||||
if not lock_path:
|
||||
return None
|
||||
return read_lock_file(lock_path)
|
||||
|
||||
|
||||
def load_issue_lock(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
return read_lock_file(
|
||||
lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
lock_dir=lock_dir,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def iter_lock_files(lock_dir: str | None = None) -> list[str]:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
paths: list[str] = []
|
||||
for name in os.listdir(root):
|
||||
if not name.endswith(".json") or name.startswith("session-"):
|
||||
continue
|
||||
paths.append(os.path.join(root, name))
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def find_lock_for_branch(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
branch_name: str,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = (branch_name or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if (
|
||||
str(lock.get("remote") or "") == remote
|
||||
and str(lock.get("org") or "") == org
|
||||
and str(lock.get("repo") or "") == repo
|
||||
and str(lock.get("branch_name") or "").strip() == target
|
||||
):
|
||||
lock = dict(lock)
|
||||
lock.setdefault("lock_file_path", path)
|
||||
return lock
|
||||
return None
|
||||
|
||||
|
||||
def _lease_now(now: datetime | None = None) -> datetime:
|
||||
return now or datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_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 lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
|
||||
if not lock:
|
||||
return None
|
||||
lease = lock.get("work_lease")
|
||||
if not isinstance(lease, dict):
|
||||
return None
|
||||
return _parse_lease_timestamp(lease.get("expires_at"))
|
||||
|
||||
|
||||
def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||
expires = lease_expires_at(lock)
|
||||
if expires is None:
|
||||
return False
|
||||
return expires <= _lease_now(now)
|
||||
|
||||
|
||||
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||
if not lock:
|
||||
return False
|
||||
lease = lock.get("work_lease")
|
||||
if not isinstance(lease, dict):
|
||||
return True
|
||||
expires = _parse_lease_timestamp(lease.get("expires_at"))
|
||||
if expires is None:
|
||||
return True
|
||||
return expires > _lease_now(now)
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
def assess_same_issue_lease_conflict(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Return a fail-closed error when a competing live lease blocks acquisition."""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
existing_issue = existing_lock.get("issue_number")
|
||||
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
|
||||
|
||||
existing_branch = existing_lock.get("branch_name")
|
||||
existing_worktree = existing_lock.get("worktree_path")
|
||||
same_owner = (
|
||||
existing_branch == branch_name
|
||||
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
||||
)
|
||||
if is_lease_expired(existing_lock, now=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 assess_foreign_lock_overwrite(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
incoming_lock: dict[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Block writes that would clobber an unrelated live lease on the same key."""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number")
|
||||
same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name")
|
||||
same_worktree = _same_realpath(
|
||||
str(existing_lock.get("worktree_path") or ""),
|
||||
str(incoming_lock.get("worktree_path") or ""),
|
||||
)
|
||||
if same_issue and same_branch and same_worktree:
|
||||
return None
|
||||
if not is_lease_live(existing_lock, now=now):
|
||||
return None
|
||||
return (
|
||||
"Refusing to overwrite a live foreign issue lock "
|
||||
f"(issue #{existing_lock.get('issue_number')}, "
|
||||
f"branch '{existing_lock.get('branch_name')}', "
|
||||
f"worktree '{existing_lock.get('worktree_path')}') (fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def find_live_lock_for_branch(
|
||||
branch_name: str,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = (branch_name or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target:
|
||||
continue
|
||||
if not is_lease_live(lock):
|
||||
continue
|
||||
record = dict(lock)
|
||||
record.setdefault("lock_file_path", path)
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def resolve_locked_branch_for_session(
|
||||
branch_name: str | None = None,
|
||||
lock_dir: str | None = None,
|
||||
) -> str:
|
||||
if branch_name:
|
||||
lock = find_live_lock_for_branch(branch_name, lock_dir)
|
||||
if lock:
|
||||
return str(lock.get("branch_name") or "")
|
||||
lock = read_session_issue_lock(lock_dir)
|
||||
return str((lock or {}).get("branch_name") or "")
|
||||
|
||||
|
||||
def has_active_issue_lock(
|
||||
branch: str,
|
||||
*,
|
||||
lock_dir: str | None = None,
|
||||
) -> bool:
|
||||
target = (branch or "").strip()
|
||||
if not target:
|
||||
return False
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target:
|
||||
continue
|
||||
if is_lease_live(lock):
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Early duplicate-work detection for author work-issue sessions (#400)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import issue_claim_heartbeat as claim_hb
|
||||
|
||||
PHASE_LOCK = "lock_issue"
|
||||
PHASE_COMMIT = "commit"
|
||||
PHASE_PUSH = "push"
|
||||
PHASE_CREATE_PR = "create_pr"
|
||||
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED = "duplicate_pr_prevented"
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED = "duplicate_branch_prevented"
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED = "duplicate_commit_prevented"
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED = "duplicate_work_not_prevented"
|
||||
|
||||
_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"})
|
||||
|
||||
|
||||
def _issue_pattern(issue_number: int) -> str:
|
||||
return f"issue-{int(issue_number)}"
|
||||
|
||||
|
||||
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
return claim_hb._linked_open_pr(issue_number, open_prs)
|
||||
|
||||
|
||||
def _matching_branches(
|
||||
issue_number: int,
|
||||
branch_names: list[str],
|
||||
*,
|
||||
locked_branch: str | None = None,
|
||||
) -> list[str]:
|
||||
pattern = _issue_pattern(issue_number)
|
||||
matches = [
|
||||
name for name in (branch_names or [])
|
||||
if pattern in (name or "").lower()
|
||||
]
|
||||
if locked_branch:
|
||||
locked = locked_branch.strip()
|
||||
matches = [name for name in matches if name != locked]
|
||||
return matches
|
||||
|
||||
|
||||
def assess_work_issue_duplicate_gate(
|
||||
issue_number: int,
|
||||
*,
|
||||
open_prs: list[dict] | None = None,
|
||||
branch_names: list[str] | None = None,
|
||||
claim_entry: dict | None = None,
|
||||
locked_branch: str | None = None,
|
||||
phase: str = PHASE_LOCK,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when duplicate work is already in flight for an issue."""
|
||||
reasons: list[str] = []
|
||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||
prs = list(open_prs or [])
|
||||
branches = list(branch_names or [])
|
||||
pattern = _issue_pattern(issue_number)
|
||||
|
||||
linked = _linked_open_pr(issue_number, prs)
|
||||
if linked:
|
||||
reasons.append(
|
||||
f"open PR #{linked.get('number')} already covers issue "
|
||||
f"#{issue_number} (fail closed)"
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
conflicting_branches = _matching_branches(
|
||||
issue_number, branches, locked_branch=locked_branch
|
||||
)
|
||||
if conflicting_branches:
|
||||
names = ", ".join(conflicting_branches[:5])
|
||||
reasons.append(
|
||||
f"remote branch(es) already match issue pattern '{pattern}': "
|
||||
f"{names} (fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
|
||||
entry = claim_entry or {}
|
||||
if entry.get("linked_open_pr") and not linked:
|
||||
reasons.append(
|
||||
f"claim inventory reports open PR #{entry['linked_open_pr']} "
|
||||
f"for issue #{issue_number} (fail closed)"
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
status = (entry.get("status") or "").strip().lower()
|
||||
if status in _ACTIVE_CLAIM_STATUSES and not linked:
|
||||
heartbeat = entry.get("latest_heartbeat") or {}
|
||||
claim_branch = (heartbeat.get("branch") or "").strip()
|
||||
if locked_branch and claim_branch and claim_branch != locked_branch:
|
||||
reasons.append(
|
||||
f"active claim lease on branch '{claim_branch}' blocks "
|
||||
f"work on '{locked_branch}' for issue #{issue_number} "
|
||||
"(fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
elif not locked_branch and status == "active":
|
||||
reasons.append(
|
||||
f"issue #{issue_number} has an active claim lease "
|
||||
"(fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
|
||||
if phase in {PHASE_COMMIT, PHASE_PUSH} and reasons:
|
||||
if outcome == OUTCOME_DUPLICATE_PR_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
|
||||
elif outcome == OUTCOME_DUPLICATE_BRANCH_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"performed": not block,
|
||||
"issue_number": issue_number,
|
||||
"phase": phase,
|
||||
"outcome": outcome,
|
||||
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
||||
"conflicting_branches": conflicting_branches,
|
||||
"claim_status": status or None,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"stop before mutating; preserve local work and produce a "
|
||||
"reconciliation handoff if a concurrent PR appeared after push"
|
||||
if block and phase == PHASE_CREATE_PR
|
||||
else "stop before mutating; do not commit or push duplicate work"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_work_issue_duplicate_report(report_text: str) -> dict[str, Any]:
|
||||
"""Require explicit duplicate-work outcome wording in work-issue reports."""
|
||||
text = (report_text or "").lower()
|
||||
markers = {
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED: (
|
||||
"duplicate pr prevented",
|
||||
"duplicate_pr_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED: (
|
||||
"duplicate branch prevented",
|
||||
"duplicate_branch_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED: (
|
||||
"duplicate commit prevented",
|
||||
"duplicate_commit_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: (
|
||||
"duplicate work not prevented",
|
||||
"duplicate_work_not_prevented",
|
||||
"no duplicate work",
|
||||
),
|
||||
}
|
||||
matched = [
|
||||
key for key, phrases in markers.items()
|
||||
if any(phrase in text for phrase in phrases)
|
||||
]
|
||||
if len(matched) != 1:
|
||||
return {
|
||||
"complete": False,
|
||||
"downgraded": True,
|
||||
"reasons": [
|
||||
"work-issue report must state exactly one duplicate-work "
|
||||
"outcome (duplicate PR/branch/commit prevented, or "
|
||||
"duplicate work not prevented)"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"complete": True,
|
||||
"downgraded": False,
|
||||
"outcome": matched[0],
|
||||
"reasons": [],
|
||||
}
|
||||
+14
-10
@@ -13,9 +13,9 @@ import subprocess
|
||||
from typing import Any
|
||||
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
import issue_lock_store
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
|
||||
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@@ -37,18 +37,22 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
|
||||
|
||||
|
||||
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
||||
if path:
|
||||
return issue_lock_store.read_lock_file(path.strip())
|
||||
return issue_lock_store.read_session_issue_lock()
|
||||
lock_path = (path or ISSUE_LOCK_FILE).strip()
|
||||
if not lock_path or not os.path.exists(lock_path):
|
||||
return None
|
||||
try:
|
||||
with open(lock_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
|
||||
if lock_path:
|
||||
lock = issue_lock_store.read_lock_file(lock_path.strip())
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
return issue_lock_store.has_active_issue_lock(branch)
|
||||
lock = read_issue_lock(lock_path)
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
|
||||
|
||||
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
||||
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
||||
|
||||
_CLEANUP_SECTION_HINT = re.compile(
|
||||
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
||||
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
||||
_CLEANUP_BLOCKER_RE = re.compile(
|
||||
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
||||
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
||||
r"deleted remote branch|delete_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
||||
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
||||
r"worktree cleanup performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
||||
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
||||
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_TASK_RE = re.compile(
|
||||
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_HEAD_BRANCH_RE = re.compile(
|
||||
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
||||
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OPEN_PR_INVENTORY_RE = re.compile(
|
||||
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
||||
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
||||
r"no other open pr references branch",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
||||
r"(?:no active (?:heartbeat|claim|lease)|"
|
||||
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
||||
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_LIST_AFTER_RE = re.compile(
|
||||
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _claims_remote_delete(text: str) -> bool:
|
||||
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _claims_worktree_remove(text: str) -> bool:
|
||||
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _branch_safety_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
||||
if not _DELETE_TASK_RE.search(text):
|
||||
missing.append("delete-branch task named (delete_branch or cleanup)")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
missing.append("merge result: merged")
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
missing.append("merge commit SHA")
|
||||
if not _PR_HEAD_BRANCH_RE.search(text):
|
||||
missing.append("merged PR head branch / deleted branch name")
|
||||
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
||||
missing.append("branch not protected proof")
|
||||
if not _OPEN_PR_INVENTORY_RE.search(text):
|
||||
missing.append("open PR inventory proof (no other PR references branch)")
|
||||
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
||||
missing.append("no active heartbeat/claim/lease proof")
|
||||
return missing
|
||||
|
||||
|
||||
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
||||
path = match.group(1).strip() if match else ""
|
||||
if not path:
|
||||
missing.append("session-owned worktree path")
|
||||
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
||||
missing.append("worktree path under branches/")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("pre-removal tracked state: clean")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("pre-removal untracked state: clean")
|
||||
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
||||
missing.append("git worktree list after removal")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_post_merge_cleanup_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
cleanup_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
||||
text = report_text or ""
|
||||
session = dict(cleanup_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
||||
blocker = (session.get("blocker") or "").strip()
|
||||
if not blocker:
|
||||
match = _CLEANUP_BLOCKER_RE.search(text)
|
||||
blocker = match.group(1).strip() if match else ""
|
||||
if blocker.upper() == CLEANUP_SKIPPED:
|
||||
blocker = ""
|
||||
if not blocker:
|
||||
reasons.append(
|
||||
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"proven": not reasons,
|
||||
"outcome": CLEANUP_SKIPPED,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"outcome": None,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
remote_delete = bool(
|
||||
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
||||
)
|
||||
worktree_remove = bool(
|
||||
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
||||
)
|
||||
|
||||
if _WRONG_BRANCH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup report claims deleted branch that is not the merged PR head branch"
|
||||
)
|
||||
|
||||
if remote_delete:
|
||||
reasons.extend(
|
||||
f"remote branch deletion missing {field}"
|
||||
for field in _branch_safety_fields_present(text)
|
||||
)
|
||||
|
||||
if worktree_remove:
|
||||
reasons.extend(
|
||||
f"worktree removal missing {field}"
|
||||
for field in _worktree_cleanup_fields_present(text)
|
||||
)
|
||||
|
||||
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
||||
pass
|
||||
|
||||
if not remote_delete and not worktree_remove:
|
||||
cleanup_mutations = re.search(
|
||||
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if cleanup_mutations:
|
||||
reasons.append(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
||||
if remote_delete or worktree_remove:
|
||||
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"outcome": outcome,
|
||||
"remote_delete_claimed": remote_delete,
|
||||
"worktree_remove_claimed": worktree_remove,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
||||
"checklist before claiming remote delete or worktree removal"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -3624,9 +3624,12 @@ def assess_work_issue_mode_isolation(report_text: str) -> dict:
|
||||
|
||||
def assess_work_issue_final_report(report_text: str) -> dict:
|
||||
"""#139: composite verifier for work-issue final reports."""
|
||||
from issue_work_duplicate_gate import assess_work_issue_duplicate_report
|
||||
|
||||
checks = {
|
||||
"workflow_source": assess_work_issue_workflow_source(report_text),
|
||||
"mode_isolation": assess_work_issue_mode_isolation(report_text),
|
||||
"duplicate_work_outcome": assess_work_issue_duplicate_report(report_text),
|
||||
}
|
||||
|
||||
reasons = []
|
||||
|
||||
+5
-11
@@ -38,21 +38,13 @@ fi
|
||||
branch="$1"
|
||||
start_ref="${2:-prgs/master}"
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
|
||||
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
|
||||
if [[ "$allow_unlinked" -eq 0 ]]; then
|
||||
locked_branch=$(python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '$repo_root')
|
||||
import issue_lock_store
|
||||
print(issue_lock_store.resolve_locked_branch_for_session('$branch'))
|
||||
")
|
||||
if [[ -z "$locked_branch" ]]; then
|
||||
echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2
|
||||
if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
|
||||
echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
|
||||
exit 2
|
||||
fi
|
||||
locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
|
||||
if [[ "$branch" != "$locked_branch" ]]; then
|
||||
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
|
||||
exit 2
|
||||
@@ -76,6 +68,8 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
worktree_name="${branch//\//-}"
|
||||
worktree_path="$repo_root/branches/$worktree_name"
|
||||
|
||||
|
||||
@@ -797,6 +797,40 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
|
||||
|
||||
If any local artifact is created after final cleanup, run and report a new final status check.
|
||||
|
||||
## 28A. Post-merge cleanup proof checklist (#402)
|
||||
|
||||
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
|
||||
|
||||
### Remote branch deletion checklist
|
||||
|
||||
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
|
||||
|
||||
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
|
||||
* Merge result: merged
|
||||
* Merge commit SHA: full 40-character SHA
|
||||
* Merged PR head branch / deleted branch: exact branch name (must match)
|
||||
* Branch protection: none / branch is not protected
|
||||
* Open PR inventory proof: no other open PR references the branch
|
||||
* Active heartbeat/claim/lease: none
|
||||
|
||||
### Local worktree removal checklist
|
||||
|
||||
When removing session-owned review/simulation worktrees under `branches/`, report:
|
||||
|
||||
* Session-owned worktree path: exact path under `branches/`
|
||||
* Pre-removal tracked state: clean
|
||||
* Pre-removal untracked state: clean
|
||||
* Git worktree list after removal: command output or equivalent proof
|
||||
|
||||
### Skipped cleanup
|
||||
|
||||
If any gate fails, report:
|
||||
|
||||
* Cleanup outcome: `CLEANUP_SKIPPED`
|
||||
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
|
||||
|
||||
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
|
||||
|
||||
## 29. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
|
||||
@@ -297,6 +297,36 @@ Report:
|
||||
|
||||
Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven.
|
||||
|
||||
### 10A. Duplicate-work gate phases (#400)
|
||||
|
||||
Before any file edits, prove duplicate-work clearance with
|
||||
`gitea_assess_work_issue_duplicate` or `gitea_lock_issue` (which runs the same
|
||||
gate). The gate checks live:
|
||||
|
||||
* open PRs linked to the issue (head branch or Closes/Fixes reference),
|
||||
* remote branches matching `issue-<number>`,
|
||||
* active claim leases from structured heartbeats.
|
||||
|
||||
Re-check immediately before:
|
||||
|
||||
* `gitea_commit_files` (commit),
|
||||
* branch push,
|
||||
* `gitea_create_pr` (PR creation).
|
||||
|
||||
If a concurrent open PR appears after work begins:
|
||||
|
||||
* before commit/push → stop and preserve local work without pushing,
|
||||
* after commit but before push → stop without pushing,
|
||||
* after push but before PR creation → stop and produce a reconciliation
|
||||
handoff instead of opening a PR.
|
||||
|
||||
Final reports must state exactly one duplicate-work outcome:
|
||||
|
||||
* `duplicate PR prevented`
|
||||
* `duplicate branch prevented`
|
||||
* `duplicate commit prevented`
|
||||
* `duplicate work not prevented`
|
||||
|
||||
## 11. Claim or lock the issue before implementation
|
||||
|
||||
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
|
||||
@@ -309,15 +339,6 @@ Do not implement unclaimed work.
|
||||
|
||||
If the claim/lock gates are broken, produce a recovery handoff.
|
||||
|
||||
### Lost lock recovery after push (non-destructive)
|
||||
|
||||
If the MCP server restarts after the issue branch was pushed but before PR creation:
|
||||
|
||||
* **Do not** delete the remote branch as the normal recovery path.
|
||||
* Call `gitea_lock_issue` again with the same issue number and exact branch name. Own-branch adoption rebinds the session when open-PR, competing-lock, and worktree safety checks pass.
|
||||
* Call `gitea_create_pr` afterward. Durable keyed locks resolve from the session pointer or by matching the PR `head` branch without manual lock seeding.
|
||||
* If adoption is blocked by an open PR, a competing live lease, or a different same-issue branch, stop and produce a recovery handoff.
|
||||
|
||||
Create a tooling issue only if this run is explicitly authorized to switch to issue-creation mode and exact `create_issue` capability is proven.
|
||||
|
||||
Report:
|
||||
@@ -726,6 +747,12 @@ Use only precise categories:
|
||||
* External-state mutations:
|
||||
* Read-only diagnostics:
|
||||
|
||||
Issue-lock file (`/tmp/gitea_issue_lock.json`) read/write/delete is always an
|
||||
external-state mutation. Never claim `External-state mutations: none` after
|
||||
seeding, restoring, or removing that file. Manual lock seeding is not a normal
|
||||
recovery path (#447); use `gitea_lock_issue` or the #442 adoption recovery path
|
||||
instead. Link broader redesign: #438.
|
||||
|
||||
`git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics.
|
||||
|
||||
If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
|
||||
|
||||
@@ -74,20 +74,21 @@ ISSUE_WRITE_ENV = {
|
||||
|
||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name}
|
||||
self._env_patcher = patch.dict(os.environ, env, clear=True)
|
||||
self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
|
||||
self._env_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._env_patcher.stop()
|
||||
self._lock_dir.cleanup()
|
||||
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
@patch("mcp_server._auth", return_value="token x")
|
||||
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
|
||||
@patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
|
||||
@patch("issue_lock_worktree.read_worktree_git_state")
|
||||
def test_lock_success_includes_artifact_warning(self, mock_state, *_mocks):
|
||||
def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks):
|
||||
mock_state.return_value = {
|
||||
"current_branch": "master",
|
||||
"porcelain_status": "?? _emit_payload.py\n",
|
||||
|
||||
@@ -76,7 +76,14 @@ class CommitFilesCapabilityBase(unittest.TestCase):
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
self._dup_fetcher_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._dup_fetcher_patcher.stop()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
|
||||
|
||||
@@ -66,10 +66,16 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
)
|
||||
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
|
||||
|
||||
import issue_lock_store
|
||||
self.lock_file_path = "/tmp/gitea_issue_lock.json"
|
||||
import issue_lock_provenance
|
||||
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self._lock_dir.name
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": 263,
|
||||
"branch": "feat/issue-263-native-commit-payloads",
|
||||
"claimant": {"username": "test-user", "profile": "test-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
self.lock_data = {
|
||||
"issue_number": 263,
|
||||
"branch_name": "feat/issue-263-native-commit-payloads",
|
||||
@@ -77,12 +83,14 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
"org": "Example-Org",
|
||||
"repo": "Example-Repo",
|
||||
"worktree_path": self.locked_worktree_path,
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
},
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease.get("claimant"),
|
||||
),
|
||||
}
|
||||
self.lock_file_path = issue_lock_store.bind_session_lock(self.lock_data)
|
||||
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(self.lock_data))
|
||||
|
||||
# Reset preflight status to bypass/pass verification in tests
|
||||
self.orig_whoami_called = mcp_server._preflight_whoami_called
|
||||
@@ -90,7 +98,14 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
self._dup_fetcher_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._dup_fetcher_patcher.stop()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
@@ -99,7 +114,8 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
|
||||
self._dir.cleanup()
|
||||
self.locked_worktree_dir.cleanup()
|
||||
self._lock_dir.cleanup()
|
||||
if os.path.exists(self.lock_file_path):
|
||||
os.remove(self.lock_file_path)
|
||||
|
||||
def _env(self, profile: str) -> dict:
|
||||
return {
|
||||
@@ -108,7 +124,6 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TEST_PORCELAIN": "",
|
||||
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||
}
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Unit tests for structured issue/branch ownership parsing (#440)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from issue_branch_ownership import ( # noqa: E402
|
||||
branch_tracks_issue,
|
||||
parse_tracked_issue_number,
|
||||
)
|
||||
|
||||
|
||||
class TestIssueBranchOwnership(unittest.TestCase):
|
||||
def test_parses_canonical_author_branch(self):
|
||||
self.assertEqual(
|
||||
parse_tracked_issue_number("feat/issue-420-server-code-parity"),
|
||||
420,
|
||||
)
|
||||
|
||||
def test_issue_4200_does_not_track_issue_420(self):
|
||||
self.assertEqual(parse_tracked_issue_number("feat/issue-4200-unrelated"), 4200)
|
||||
self.assertFalse(branch_tracks_issue("feat/issue-4200-unrelated", 420))
|
||||
|
||||
def test_branch_tracks_issue_exact_match(self):
|
||||
self.assertTrue(
|
||||
branch_tracks_issue("fix/issue-440-branch-recovery", 440)
|
||||
)
|
||||
|
||||
def test_unrelated_branch_does_not_track(self):
|
||||
self.assertFalse(branch_tracks_issue("feat/issue-999-other", 440))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Unit tests for own-branch lock adoption decision (#442 / #443)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from issue_lock_adoption import ( # noqa: E402
|
||||
ADOPT,
|
||||
BLOCK_COMPETING,
|
||||
NO_MATCH,
|
||||
assess_own_branch_adoption,
|
||||
build_adoption_proof,
|
||||
)
|
||||
|
||||
REQ = "feat/issue-420-server-code-parity"
|
||||
|
||||
|
||||
class TestAssessOwnBranchAdoption(unittest.TestCase):
|
||||
def test_exact_own_branch_is_adopted(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], ADOPT)
|
||||
self.assertTrue(result["adopt"])
|
||||
|
||||
def test_different_branch_same_issue_blocks(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-420-other-work"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_no_matching_branch_is_normal_path(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-999-unrelated"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], NO_MATCH)
|
||||
|
||||
def test_issue_number_substring_collision_is_ignored(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-4200-unrelated"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], NO_MATCH)
|
||||
|
||||
|
||||
class TestBuildAdoptionProof(unittest.TestCase):
|
||||
def test_proof_has_required_fields(self):
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
||||
)
|
||||
proof = build_adoption_proof(
|
||||
issue_number=420,
|
||||
branch_name=REQ,
|
||||
assessment=assessment,
|
||||
open_pr_checked=True,
|
||||
competing_lock_checked=True,
|
||||
lock_file_path="/tmp/example-lock.json",
|
||||
lock_file_status="written",
|
||||
)
|
||||
self.assertEqual(proof["branch_head_commit"], "934688a")
|
||||
self.assertTrue(proof["no_existing_pr_proof"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for issue-lock provenance and external-state disclosure (#447)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_provenance as ilp # noqa: E402
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
|
||||
|
||||
def _sanctioned_lock(**overrides):
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": 447,
|
||||
"branch": "feat/issue-447-lock-provenance",
|
||||
"claimant": {"username": "jcwalker3", "profile": "prgs-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
data = {
|
||||
"issue_number": 447,
|
||||
"branch_name": "feat/issue-447-lock-provenance",
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": ilp.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease["claimant"],
|
||||
),
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
class TestLockProvenanceForCreatePr(unittest.TestCase):
|
||||
def test_sanctioned_lock_passes(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(_sanctioned_lock())
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_manual_seed_without_provenance_blocked(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(
|
||||
{"issue_number": 420, "branch_name": "feat/x", "work_lease": {}}
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("lock_provenance", result["reasons"][0])
|
||||
|
||||
def test_operator_override_requires_reason(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(
|
||||
_sanctioned_lock(
|
||||
lock_provenance=ilp.build_sanctioned_lock_provenance(
|
||||
tool="operator_override",
|
||||
source=ilp.SOURCE_OPERATOR_OVERRIDE,
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestExternalStateReportRules(unittest.TestCase):
|
||||
def test_seed_with_external_none_blocked(self):
|
||||
report = (
|
||||
"Restored /tmp/gitea_issue_lock.json to unblock PR creation.\n"
|
||||
"- External-state mutations: none\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_seed_with_disclosure_passes(self):
|
||||
report = (
|
||||
"Restored /tmp/gitea_issue_lock.json after MCP restart.\n"
|
||||
"- External-state mutations: wrote /tmp/gitea_issue_lock.json\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_remove_claimed_as_cleanup_only_blocked(self):
|
||||
report = (
|
||||
"rm /tmp/gitea_issue_lock.json after PR creation.\n"
|
||||
"- Cleanup mutations: lock removed\n"
|
||||
"- External-state mutations: none\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_manual_lock_pr_without_override_blocked(self):
|
||||
report = (
|
||||
"Programmatically seeded gitea_issue_lock.json then gitea_create_pr.\n"
|
||||
"PR #444 created.\n"
|
||||
)
|
||||
result = ilp.assess_manual_lock_pr_without_override(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_author_reviewer_same_run_blocked(self):
|
||||
report = (
|
||||
"gitea_create_pr opened PR #444.\n"
|
||||
"Submitted approve review on PR #444.\n"
|
||||
)
|
||||
result = ilp.assess_author_reviewer_same_run_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestFinalReportValidatorIntegration(unittest.TestCase):
|
||||
def test_work_issue_blocks_hidden_lock_mutation(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Task: work issue #420\n"
|
||||
"- External-state mutations: none\n"
|
||||
"Restored /tmp/gitea_issue_lock.json before PR creation.\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, "work_issue")
|
||||
rule_ids = {f["rule_id"] for f in result["findings"]}
|
||||
self.assertIn("shared.issue_lock_external_state", rule_ids)
|
||||
self.assertTrue(result["blocked"])
|
||||
|
||||
def test_review_pr_blocks_create_and_approve(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Task: review PR #444\n"
|
||||
"- Review decision: approve\n"
|
||||
"Created PR #444 via gitea_create_pr earlier in this run.\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
rule_ids = {f["rule_id"] for f in result["findings"]}
|
||||
self.assertIn("shared.author_reviewer_same_run", rule_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,152 +0,0 @@
|
||||
"""Integration tests for non-destructive lock/PR recovery (#440)."""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import issue_lock_store as ils # noqa: E402
|
||||
import mcp_server # noqa: E402
|
||||
from mcp_server import gitea_create_pr, gitea_lock_issue # noqa: E402
|
||||
from tests.test_mcp_server import CREATE_PR_ENV, ISSUE_WRITE_ENV # noqa: E402
|
||||
|
||||
FAKE_AUTH = "token fake"
|
||||
BRANCH = "feat/issue-420-server-code-parity"
|
||||
|
||||
|
||||
def _lock_record(**overrides):
|
||||
record = {
|
||||
"issue_number": 420,
|
||||
"branch_name": BRANCH,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": mcp_server.REMOTES["prgs"]["repo"],
|
||||
"worktree_path": "/tmp/wt-420",
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
def _clean_git_state():
|
||||
return {
|
||||
"current_branch": BRANCH,
|
||||
"porcelain_status": "",
|
||||
"base_equivalent": True,
|
||||
"inspected_git_root": os.getcwd(),
|
||||
"base_branch": "master",
|
||||
}
|
||||
|
||||
|
||||
class TestIssueLockRecovery(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmpdir = tempfile.TemporaryDirectory()
|
||||
self._lock_dir = self._tmpdir.name
|
||||
self._env = {
|
||||
**ISSUE_WRITE_ENV,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir,
|
||||
}
|
||||
self._create_pr_env = {
|
||||
**CREATE_PR_ENV,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def test_adoption_after_restart_without_session_pointer(self):
|
||||
with patch.dict(os.environ, self._env, clear=True):
|
||||
with mock.patch("os.getpid", return_value=9999):
|
||||
self.assertIsNone(ils.read_session_issue_lock())
|
||||
|
||||
with mock.patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_git_state(),
|
||||
), mock.patch("mcp_server.api_get_all") as mock_api, mock.patch(
|
||||
"mcp_server.get_auth_header", return_value=FAKE_AUTH
|
||||
):
|
||||
mock_api.side_effect = [
|
||||
[],
|
||||
[{"name": BRANCH, "commit": {"id": "934688a"}}],
|
||||
]
|
||||
res = gitea_lock_issue(
|
||||
issue_number=420,
|
||||
branch_name=BRANCH,
|
||||
remote="prgs",
|
||||
worktree_path=os.path.realpath(os.getcwd()),
|
||||
)
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("adoption", res)
|
||||
self.assertEqual(res["adoption"]["branch_head_commit"], "934688a")
|
||||
|
||||
@mock.patch(
|
||||
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
)
|
||||
@mock.patch("mcp_server.api_request")
|
||||
@mock.patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_resolves_keyed_lock_after_restart(self, _auth, mock_api, _role):
|
||||
mock_api.return_value = {"number": 501, "html_url": "https://example/pr/501"}
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
with patch.dict(os.environ, self._create_pr_env, clear=True):
|
||||
path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org=mcp_server.REMOTES["prgs"]["org"],
|
||||
repo=mcp_server.REMOTES["prgs"]["repo"],
|
||||
issue_number=420,
|
||||
lock_dir=self._lock_dir,
|
||||
)
|
||||
ils.save_lock_file(path, _lock_record(worktree_path=worktree))
|
||||
|
||||
with mock.patch("os.getpid", return_value=4242):
|
||||
self.assertIsNone(ils.read_session_issue_lock())
|
||||
|
||||
res = gitea_create_pr(
|
||||
title="feat: recovery Closes #420",
|
||||
head=BRANCH,
|
||||
base="master",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
self.assertEqual(res["number"], 501)
|
||||
|
||||
def test_open_pr_blocks_adoption(self):
|
||||
with patch.dict(os.environ, self._env, clear=True):
|
||||
with mock.patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_git_state(),
|
||||
), mock.patch("mcp_server.api_get_all") as mock_api, mock.patch(
|
||||
"mcp_server.get_auth_header", return_value=FAKE_AUTH
|
||||
):
|
||||
mock_api.side_effect = [
|
||||
[{"number": 99, "head": {"ref": BRANCH}, "title": "", "body": ""}],
|
||||
[{"name": BRANCH, "commit": {"id": "934688a"}}],
|
||||
]
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_lock_issue(
|
||||
issue_number=420,
|
||||
branch_name=BRANCH,
|
||||
remote="prgs",
|
||||
worktree_path=os.path.realpath(os.getcwd()),
|
||||
)
|
||||
self.assertIn("already tied to an open PR", str(ctx.exception))
|
||||
|
||||
def test_structured_ownership_ignores_issue_4200_collision(self):
|
||||
result = issue_lock_adoption.assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=BRANCH,
|
||||
existing_branches=[{"name": "feat/issue-4200-unrelated"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], issue_lock_adoption.NO_MATCH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Unit tests for keyed issue-lock storage (#443)."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_store as ils # noqa: E402
|
||||
|
||||
|
||||
def _lease(expires_at: str) -> dict:
|
||||
return {
|
||||
"operation_type": ils.AUTHOR_ISSUE_WORK_LEASE,
|
||||
"expires_at": expires_at,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"last_heartbeat_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
def _lock_record(**overrides) -> dict:
|
||||
record = {
|
||||
"issue_number": 420,
|
||||
"branch_name": "feat/issue-420-server-code-parity",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"worktree_path": "/tmp/wt-420",
|
||||
"work_lease": _lease("2999-01-01T00:00:00Z"),
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
class TestIssueLockStore(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.lock_dir = self._dir.name
|
||||
self._env = mock.patch.dict(os.environ, {"GITEA_ISSUE_LOCK_DIR": self.lock_dir})
|
||||
self._env.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._env.stop()
|
||||
self._dir.cleanup()
|
||||
|
||||
def test_concurrent_repo_locks_do_not_overwrite(self):
|
||||
lock_a = _lock_record(
|
||||
issue_number=108,
|
||||
branch_name="feat/issue-108-root-menu",
|
||||
repo="mcp-control-plane",
|
||||
worktree_path="/tmp/wt-108",
|
||||
)
|
||||
lock_b = _lock_record(
|
||||
issue_number=420,
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/tmp/wt-420",
|
||||
)
|
||||
path_a = ils.bind_session_lock(lock_a)
|
||||
with mock.patch("os.getpid", return_value=9999):
|
||||
path_b = ils.bind_session_lock(lock_b)
|
||||
|
||||
self.assertNotEqual(path_a, path_b)
|
||||
self.assertTrue(os.path.exists(path_a))
|
||||
self.assertTrue(os.path.exists(path_b))
|
||||
stored_a = ils.read_lock_file(path_a)
|
||||
stored_b = ils.read_lock_file(path_b)
|
||||
self.assertEqual(stored_a["issue_number"], 108)
|
||||
self.assertEqual(stored_b["issue_number"], 420)
|
||||
|
||||
def test_concurrent_issue_locks_same_repo_do_not_overwrite(self):
|
||||
lock_a = _lock_record(issue_number=427, branch_name="feat/issue-427-a")
|
||||
lock_b = _lock_record(issue_number=428, branch_name="feat/issue-428-b")
|
||||
path_a = ils.bind_session_lock(lock_a)
|
||||
with mock.patch("os.getpid", return_value=4242):
|
||||
path_b = ils.bind_session_lock(lock_b)
|
||||
|
||||
self.assertNotEqual(path_a, path_b)
|
||||
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 427)
|
||||
self.assertEqual(ils.read_lock_file(path_b)["issue_number"], 428)
|
||||
|
||||
def test_foreign_live_lease_blocks_overwrite(self):
|
||||
existing = _lock_record(
|
||||
branch_name="feat/issue-420-other",
|
||||
worktree_path="/tmp/other",
|
||||
work_lease=_lease("2999-01-01T00:00:00Z"),
|
||||
)
|
||||
path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=420,
|
||||
)
|
||||
ils.save_lock_file(path, existing)
|
||||
|
||||
incoming = _lock_record(worktree_path="/tmp/mine")
|
||||
block = ils.assess_foreign_lock_overwrite(existing, incoming)
|
||||
self.assertIn("live foreign issue lock", block or "")
|
||||
|
||||
def test_expired_lease_allows_takeover_with_conflict_check(self):
|
||||
existing = _lock_record(
|
||||
branch_name="feat/issue-420-other",
|
||||
worktree_path="/tmp/other",
|
||||
work_lease=_lease("2000-01-01T00:00:00Z"),
|
||||
)
|
||||
incoming = _lock_record(worktree_path="/tmp/mine")
|
||||
self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming))
|
||||
block = ils.assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=420,
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
worktree_path="/tmp/mine",
|
||||
)
|
||||
self.assertIn("Recovery review is required", block or "")
|
||||
|
||||
def test_same_owner_lease_conflict_allows_refresh(self):
|
||||
worktree = "/tmp/wt-420"
|
||||
existing = _lock_record(worktree_path=worktree)
|
||||
block = ils.assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=420,
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
self.assertIsNone(block)
|
||||
|
||||
def test_find_lock_for_branch_after_restart(self):
|
||||
record = _lock_record()
|
||||
path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=420,
|
||||
)
|
||||
ils.save_lock_file(path, record)
|
||||
|
||||
with mock.patch("os.getpid", return_value=5555):
|
||||
self.assertIsNone(ils.read_session_issue_lock())
|
||||
|
||||
found = ils.find_lock_for_branch(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
)
|
||||
self.assertEqual(found["issue_number"], 420)
|
||||
|
||||
def test_has_active_issue_lock_scans_keyed_store(self):
|
||||
ils.bind_session_lock(_lock_record())
|
||||
self.assertTrue(
|
||||
ils.has_active_issue_lock("feat/issue-420-server-code-parity")
|
||||
)
|
||||
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
|
||||
|
||||
def test_atomic_write_preserves_unrelated_lock(self):
|
||||
path_a = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=108,
|
||||
)
|
||||
ils.save_lock_file(path_a, _lock_record(issue_number=108, repo="mcp-control-plane"))
|
||||
path_b = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=420,
|
||||
)
|
||||
ils.save_lock_file(path_b, _lock_record())
|
||||
|
||||
self.assertTrue(os.path.exists(path_a))
|
||||
self.assertTrue(os.path.exists(path_b))
|
||||
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 108)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests for early duplicate-work detection (#400)."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_work_duplicate_gate as dup_gate
|
||||
import mcp_server
|
||||
from issue_work_duplicate_gate import (
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED,
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED,
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED,
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
|
||||
PHASE_COMMIT,
|
||||
PHASE_CREATE_PR,
|
||||
PHASE_LOCK,
|
||||
assess_work_issue_duplicate_gate,
|
||||
assess_work_issue_duplicate_report,
|
||||
)
|
||||
|
||||
|
||||
class TestDuplicateGateAssessment(unittest.TestCase):
|
||||
def test_clear_issue_passes(self):
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
400,
|
||||
open_prs=[],
|
||||
branch_names=["feat/other-issue-99"],
|
||||
claim_entry={"status": "not_claimed"},
|
||||
locked_branch="feat/issue-400-duplicate-work-preflight",
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
|
||||
|
||||
def test_open_pr_blocks(self):
|
||||
prs = [{
|
||||
"number": 397,
|
||||
"title": "feat: handoff",
|
||||
"body": "Closes #395",
|
||||
"head": {"ref": "feat/issue-395-proof-backed-review-handoff"},
|
||||
}]
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
395,
|
||||
open_prs=prs,
|
||||
branch_names=[],
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
|
||||
|
||||
def test_conflicting_remote_branch_blocks(self):
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
395,
|
||||
open_prs=[],
|
||||
branch_names=["feat/issue-395-proof-backed-handoff-claims"],
|
||||
locked_branch="feat/issue-395-new-attempt",
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_BRANCH_PREVENTED)
|
||||
|
||||
def test_active_claim_on_other_branch_blocks(self):
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
398,
|
||||
open_prs=[],
|
||||
branch_names=[],
|
||||
claim_entry={
|
||||
"status": "active",
|
||||
"latest_heartbeat": {
|
||||
"branch": "feat/issue-398-validation-cwd-proof",
|
||||
},
|
||||
},
|
||||
locked_branch="feat/issue-398-other-branch",
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_commit_phase_maps_to_commit_outcome(self):
|
||||
prs = [{
|
||||
"number": 411,
|
||||
"title": "x",
|
||||
"body": "Closes #398",
|
||||
"head": {"ref": "feat/issue-398-validation-cwd-proof"},
|
||||
}]
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
398,
|
||||
open_prs=prs,
|
||||
branch_names=[],
|
||||
locked_branch="feat/issue-398-alt",
|
||||
phase=PHASE_COMMIT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_COMMIT_PREVENTED)
|
||||
|
||||
def test_stale_claim_does_not_block_by_status_alone(self):
|
||||
result = assess_work_issue_duplicate_gate(
|
||||
400,
|
||||
open_prs=[],
|
||||
branch_names=[],
|
||||
claim_entry={"status": "reclaimable", "reasons": ["stale"]},
|
||||
locked_branch="feat/issue-400-duplicate-work-preflight",
|
||||
phase=PHASE_LOCK,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
|
||||
class TestDuplicateReportOutcome(unittest.TestCase):
|
||||
def test_requires_exactly_one_outcome(self):
|
||||
bad = assess_work_issue_duplicate_report("work finished")
|
||||
self.assertFalse(bad["complete"])
|
||||
|
||||
good = assess_work_issue_duplicate_report(
|
||||
"Duplicate work not prevented for issue #400."
|
||||
)
|
||||
self.assertTrue(good["complete"])
|
||||
self.assertEqual(good["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
|
||||
|
||||
|
||||
class TestInjectableDuplicateFetcher(unittest.TestCase):
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_lock_issue_uses_injected_fetcher(self, _auth):
|
||||
seen = {}
|
||||
|
||||
def fetcher(h, o, r, auth, issue_number):
|
||||
seen["issue_number"] = issue_number
|
||||
return [], [], {"status": "not_claimed"}
|
||||
|
||||
with patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
side_effect=fetcher,
|
||||
), patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={
|
||||
"current_branch": "master",
|
||||
"porcelain_status": "",
|
||||
"base_equivalent": True,
|
||||
},
|
||||
), patch.dict(os.environ, {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
||||
}, clear=True):
|
||||
with patch.object(mcp_server, "ISSUE_LOCK_FILE", tempfile.mktemp()):
|
||||
mcp_server.gitea_lock_issue(
|
||||
issue_number=400,
|
||||
branch_name="feat/issue-400-duplicate-work-preflight",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertEqual(seen["issue_number"], 400)
|
||||
|
||||
|
||||
class TestMcpDuplicateRecheck(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.lock_path = os.path.join(self._dir.name, "gitea_issue_lock.json")
|
||||
self._lock_patch = patch.object(
|
||||
mcp_server, "ISSUE_LOCK_FILE", self.lock_path
|
||||
)
|
||||
self._lock_patch.start()
|
||||
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||
"repo": "Example-Repo"},
|
||||
})
|
||||
self._remotes.start()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
self._dir.cleanup()
|
||||
|
||||
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
|
||||
with open(self.lock_path, "w", encoding="utf-8") as fh:
|
||||
json.dump({
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch,
|
||||
"remote": "prgs",
|
||||
}, fh)
|
||||
|
||||
@patch("mcp_server._assess_issue_duplicate_gate")
|
||||
@patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "test-author",
|
||||
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-author",
|
||||
})
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
|
||||
self._write_lock()
|
||||
mock_gate.return_value = {
|
||||
"block": True,
|
||||
"reasons": ["open PR #412 already covers issue #400"],
|
||||
"outcome": OUTCOME_DUPLICATE_COMMIT_PREVENTED,
|
||||
"safe_next_action": "stop",
|
||||
}
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
with patch(
|
||||
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
):
|
||||
result = mcp_server.gitea_commit_files(
|
||||
files=[{
|
||||
"operation": "create",
|
||||
"path": "a.txt",
|
||||
"content_plain": "hi",
|
||||
}],
|
||||
message="test",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIn("duplicate_gate", result)
|
||||
|
||||
@patch("mcp_server._assess_issue_duplicate_gate")
|
||||
@patch("mcp_server.get_profile", return_value={
|
||||
"profile_name": "test-author",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.create"],
|
||||
"forbidden_operations": [],
|
||||
"audit_label": "test-author",
|
||||
})
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
|
||||
self._write_lock()
|
||||
mock_gate.return_value = {
|
||||
"block": True,
|
||||
"reasons": ["open PR #412 already covers issue #400"],
|
||||
"outcome": OUTCOME_DUPLICATE_PR_PREVENTED,
|
||||
"safe_next_action": "reconciliation handoff",
|
||||
}
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||
with patch(
|
||||
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
):
|
||||
result = mcp_server.gitea_create_pr(
|
||||
title="feat: x (Closes #400)",
|
||||
head="feat/issue-400-x",
|
||||
base="master",
|
||||
body="Closes #400",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIsNone(result.get("number"))
|
||||
self.assertIn("duplicate_gate", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+145
-209
@@ -6,7 +6,6 @@ the MCP protocol) with mocked API responses.
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
@@ -46,7 +45,6 @@ from gitea_auth import get_profile # noqa: E402
|
||||
import gitea_config # noqa: E402
|
||||
|
||||
import mcp_server
|
||||
import issue_lock_store
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
|
||||
@@ -99,32 +97,38 @@ CREATE_PR_ENV = {
|
||||
),
|
||||
}
|
||||
|
||||
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
|
||||
|
||||
|
||||
def _sample_issue_lock(issue_number=123, branch_name="feat/x", **overrides):
|
||||
import issue_lock_provenance
|
||||
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": issue_number,
|
||||
"branch": branch_name,
|
||||
"claimant": {"username": "test-user", "profile": "test-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
record = {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"remote": "dadeschools",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"worktree_path": "/tmp/test-worktree",
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
},
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease.get("claimant"),
|
||||
),
|
||||
}
|
||||
record.update(overrides)
|
||||
return record
|
||||
|
||||
|
||||
def _bind_test_lock(**overrides) -> str:
|
||||
remote = overrides.get("remote", "dadeschools")
|
||||
record = _sample_issue_lock(**overrides)
|
||||
if remote in mcp_server.REMOTES:
|
||||
profile = mcp_server.REMOTES[remote]
|
||||
record.setdefault("org", profile["org"])
|
||||
record.setdefault("repo", profile["repo"])
|
||||
record["remote"] = remote
|
||||
return issue_lock_store.bind_session_lock(record)
|
||||
def _clear_duplicate_context_fetcher(*_args, **_kwargs):
|
||||
"""Default injectable duplicate-work context for lock/create_pr tests."""
|
||||
return [], [], {"status": "not_claimed"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -181,70 +185,63 @@ class TestCreateIssue(unittest.TestCase):
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestCreatePR(unittest.TestCase):
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_creates_pr(self, _auth, mock_api, _role):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("builtins.open")
|
||||
def test_creates_pr(self, mock_open, mock_exists, _auth, mock_api, _role, _dup_fetcher):
|
||||
lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = lock_json
|
||||
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||
result = gitea_create_pr(
|
||||
title="feat: X Closes #123",
|
||||
head="feat/x",
|
||||
base="main",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
|
||||
self.assertEqual(result["number"], 3)
|
||||
self.assertNotIn("url", result)
|
||||
mock_exists.assert_called_with(ISSUE_LOCK_FILE)
|
||||
mock_open.assert_called_with(ISSUE_LOCK_FILE, "r", encoding="utf-8")
|
||||
payload = mock_api.call_args[0][3]
|
||||
self.assertEqual(payload["head"], "feat/x")
|
||||
self.assertEqual(payload["base"], "main")
|
||||
self.assertIn("Closes #123", payload["title"])
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("builtins.open")
|
||||
def test_create_pr_reveal_opt_in_includes_url(self, mock_open, mock_exists, _auth, mock_api, _role, _dup_fetcher):
|
||||
lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = lock_json
|
||||
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
|
||||
result = gitea_create_pr(
|
||||
title="feat: X Closes #123",
|
||||
head="feat/x",
|
||||
base="main",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
env = {**CREATE_PR_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
|
||||
self.assertIn("pulls/3", result["url"])
|
||||
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_locked_issue_mismatch_fails(self, _auth, _role):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
_bind_test_lock(
|
||||
issue_number=123,
|
||||
branch_name="feat/x",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_create_pr(
|
||||
title="feat: X Closes #999",
|
||||
head="feat/x",
|
||||
base="main",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("builtins.open")
|
||||
def test_create_pr_locked_issue_mismatch_fails(self, mock_open, mock_exists, _auth, _role):
|
||||
lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
|
||||
mock_open.return_value.__enter__.return_value.read.return_value = lock_json
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_create_pr(title="feat: X Closes #999", head="feat/x", base="main")
|
||||
self.assertIn("Closes #123", str(ctx.exception))
|
||||
mock_open.assert_called_with(ISSUE_LOCK_FILE, "r", encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3072,32 +3069,26 @@ class TestIssueLocking(unittest.TestCase):
|
||||
"""Test issue locking and PR gating constraints."""
|
||||
|
||||
def setUp(self):
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
env = {
|
||||
**ISSUE_WRITE_ENV,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||
}
|
||||
self._env_patcher = patch.dict(os.environ, env, clear=True)
|
||||
self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
|
||||
self._env_patcher.start()
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
self.mock_dup_fetcher = self._dup_fetcher_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._dup_fetcher_patcher.stop()
|
||||
self._env_patcher.stop()
|
||||
self._lock_dir.cleanup()
|
||||
|
||||
def _create_pr_env(self) -> dict:
|
||||
return {
|
||||
**CREATE_PR_ENV,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||
}
|
||||
if os.path.exists(ISSUE_LOCK_FILE):
|
||||
os.remove(ISSUE_LOCK_FILE)
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_success(self, _auth, mock_api, _git_state):
|
||||
mock_api.return_value = [] # no open PRs
|
||||
def test_lock_issue_success(self, _auth, _git_state):
|
||||
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertEqual(res["work_lease"]["operation_type"], "author_issue_work")
|
||||
@@ -3107,8 +3098,9 @@ class TestIssueLocking(unittest.TestCase):
|
||||
self.assertIn("expires_at", res["work_lease"])
|
||||
self.assertIn("last_heartbeat_at", res["work_lease"])
|
||||
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
|
||||
self.assertIn("lock_file_path", res)
|
||||
lock = issue_lock_store.read_lock_file(res["lock_file_path"])
|
||||
self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
|
||||
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
|
||||
lock = json.load(f)
|
||||
self.assertIn("worktree_path", lock)
|
||||
self.assertIn("work_lease", lock)
|
||||
|
||||
@@ -3121,128 +3113,75 @@ class TestIssueLocking(unittest.TestCase):
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_reused_by_open_pr_branch(self, _auth, mock_api, _git_state):
|
||||
mock_api.return_value = [{
|
||||
def test_lock_issue_reused_by_open_pr_branch(self, _auth, _git_state):
|
||||
self.mock_dup_fetcher.return_value = ([{
|
||||
"number": 200,
|
||||
"head": {"ref": "feat/issue-196-boundary"},
|
||||
"title": "Some PR",
|
||||
"body": "No closes ref"
|
||||
}]
|
||||
"body": "No closes ref",
|
||||
}], [], {"status": "not_claimed"})
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("already tied to an open PR", str(ctx.exception))
|
||||
self.assertIn("open PR #200 already covers issue", str(ctx.exception))
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, mock_api, _git_state):
|
||||
mock_api.return_value = [{
|
||||
def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, _git_state):
|
||||
self.mock_dup_fetcher.return_value = ([{
|
||||
"number": 200,
|
||||
"head": {"ref": "feat/other-branch"},
|
||||
"title": "Some PR",
|
||||
"body": "fixes #196"
|
||||
}]
|
||||
"body": "fixes #196",
|
||||
}], [], {"status": "not_claimed"})
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("already tied to an open PR", str(ctx.exception))
|
||||
self.assertIn("open PR #200 already covers issue", str(ctx.exception))
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_reused_by_remote_branch(self, _auth, mock_api, _git_state):
|
||||
mock_api.side_effect = [
|
||||
def test_lock_issue_reused_by_remote_branch(self, _auth, _git_state):
|
||||
self.mock_dup_fetcher.return_value = (
|
||||
[],
|
||||
[{"name": "feat/issue-196-existing-work"}],
|
||||
]
|
||||
["feat/issue-196-existing-work"],
|
||||
{"status": "not_claimed"},
|
||||
)
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("not the requested branch", str(ctx.exception))
|
||||
self.assertIn("remote branch(es) already match issue pattern", str(ctx.exception))
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_adopts_exact_own_branch(self, _auth, mock_api, _git_state):
|
||||
branch = "feat/issue-196-mutations"
|
||||
mock_api.side_effect = [
|
||||
[],
|
||||
[{"name": branch, "commit": {"id": "abc123"}}],
|
||||
]
|
||||
res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs")
|
||||
self.assertTrue(res["success"])
|
||||
self.assertIn("adoption", res)
|
||||
self.assertEqual(res["adoption"]["branch_head_commit"], "abc123")
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state):
|
||||
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
||||
issue_lock_store.save_lock_file(
|
||||
issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo=prgs_repo,
|
||||
issue_number=196,
|
||||
),
|
||||
{
|
||||
def test_lock_issue_blocks_active_same_operation_lease(self):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"issue_number": 196,
|
||||
"branch_name": "feat/issue-196-other-work",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": prgs_repo,
|
||||
"worktree_path": "/tmp/other-worktree",
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
)
|
||||
}, f)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("already has an active author_issue_work lease", str(ctx.exception))
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value=_clean_master_git_state_for_lock(),
|
||||
)
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self, _auth, _api, _git_state):
|
||||
prgs_repo = mcp_server.REMOTES["prgs"]["repo"]
|
||||
issue_lock_store.save_lock_file(
|
||||
issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo=prgs_repo,
|
||||
issue_number=196,
|
||||
),
|
||||
{
|
||||
def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"issue_number": 196,
|
||||
"branch_name": "feat/issue-196-other-work",
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": prgs_repo,
|
||||
"worktree_path": "/tmp/other-worktree",
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2000-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
)
|
||||
}, f)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("Recovery review is required before takeover", str(ctx.exception))
|
||||
@@ -3309,7 +3248,9 @@ class TestIssueLocking(unittest.TestCase):
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_missing_lock_fails(self, _auth, _role):
|
||||
with patch.dict(os.environ, self._create_pr_env(), clear=True):
|
||||
if os.path.exists(ISSUE_LOCK_FILE):
|
||||
os.remove(ISSUE_LOCK_FILE)
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("Issue lock is missing", str(ctx.exception))
|
||||
@@ -3318,64 +3259,37 @@ class TestIssueLocking(unittest.TestCase):
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_branch_mismatch_fails(self, _auth, _role):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
_bind_test_lock(
|
||||
issue_number=196,
|
||||
branch_name="feat/issue-196-mutations",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
with patch.dict(os.environ, self._create_pr_env(), clear=True):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(_sample_issue_lock(
|
||||
issue_number=196, branch_name="feat/issue-196-mutations"), f)
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_create_pr(
|
||||
title="feat: X Closes #196",
|
||||
head="feat/issue-196-different",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-different", remote="prgs")
|
||||
self.assertIn("does not match locked branch", str(ctx.exception))
|
||||
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_forbidden_terms_fails(self, _auth, _role):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
_bind_test_lock(
|
||||
issue_number=196,
|
||||
branch_name="feat/issue-196-mutations",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
with patch.dict(os.environ, self._create_pr_env(), clear=True):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(_sample_issue_lock(
|
||||
issue_number=196, branch_name="feat/issue-196-mutations"), f)
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
for term in ("equivalent to #196", "related to #196", "same as #196"):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_create_pr(
|
||||
title=f"feat: X {term}",
|
||||
head="feat/issue-196-mutations",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
gitea_create_pr(title=f"feat: X {term}", head="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("contains forbidden term", str(ctx.exception))
|
||||
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_missing_closes_ref_fails(self, _auth, _role):
|
||||
worktree = os.path.realpath(os.getcwd())
|
||||
_bind_test_lock(
|
||||
issue_number=196,
|
||||
branch_name="feat/issue-196-mutations",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
with patch.dict(os.environ, self._create_pr_env(), clear=True):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(_sample_issue_lock(
|
||||
issue_number=196, branch_name="feat/issue-196-mutations"), f)
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_create_pr(
|
||||
title="feat: X refs #196",
|
||||
head="feat/issue-196-mutations",
|
||||
remote="prgs",
|
||||
worktree_path=worktree,
|
||||
)
|
||||
gitea_create_pr(title="feat: X refs #196", head="feat/issue-196-mutations", remote="prgs")
|
||||
self.assertIn("must contain 'Closes #196' or 'Fixes #196' exactly", str(ctx.exception))
|
||||
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
@@ -3383,13 +3297,13 @@ class TestIssueLocking(unittest.TestCase):
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_worktree_mismatch_fails(self, _auth, _role):
|
||||
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-pr")
|
||||
_bind_test_lock(
|
||||
issue_number=249,
|
||||
branch_name="feat/issue-249-issue-lock-scratch-worktree",
|
||||
worktree_path=scratch,
|
||||
remote="prgs",
|
||||
)
|
||||
with patch.dict(os.environ, self._create_pr_env(), clear=True):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(_sample_issue_lock(
|
||||
issue_number=249,
|
||||
branch_name="feat/issue-249-issue-lock-scratch-worktree",
|
||||
worktree_path=scratch,
|
||||
), f)
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
gitea_create_pr(
|
||||
title="feat: lock scratch worktree Closes #249",
|
||||
@@ -3399,6 +3313,28 @@ class TestIssueLocking(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("does not match locked worktree", str(ctx.exception))
|
||||
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_create_pr_manual_lock_seed_blocked(self, _auth, _role):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
_sample_issue_lock(
|
||||
issue_number=447,
|
||||
branch_name="feat/issue-447-lock-provenance",
|
||||
lock_provenance=None,
|
||||
),
|
||||
f,
|
||||
)
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
gitea_create_pr(
|
||||
title="feat: lock provenance Closes #447",
|
||||
head="feat/issue-447-lock-provenance",
|
||||
remote="prgs",
|
||||
)
|
||||
self.assertIn("lock provenance", str(ctx.exception).lower())
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []))
|
||||
@@ -3406,13 +3342,13 @@ class TestIssueLocking(unittest.TestCase):
|
||||
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api):
|
||||
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e")
|
||||
mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"}
|
||||
_bind_test_lock(
|
||||
issue_number=249,
|
||||
branch_name="feat/issue-249-issue-lock-scratch-worktree",
|
||||
worktree_path=scratch,
|
||||
remote="prgs",
|
||||
)
|
||||
with patch.dict(os.environ, self._create_pr_env(), clear=True):
|
||||
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(_sample_issue_lock(
|
||||
issue_number=249,
|
||||
branch_name="feat/issue-249-issue-lock-scratch-worktree",
|
||||
worktree_path=scratch,
|
||||
), f)
|
||||
with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
|
||||
res = gitea_create_pr(
|
||||
title="feat: issue-lock scratch worktree Closes #249",
|
||||
head="feat/issue-249-issue-lock-scratch-worktree",
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for post-merge cleanup proof enforcement (#402)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from post_merge_cleanup_proof import ( # noqa: E402
|
||||
CLEANUP_SKIPPED,
|
||||
assess_post_merge_cleanup_proof,
|
||||
)
|
||||
|
||||
MERGE_SHA = "a" * 40
|
||||
HEAD_BRANCH = "feat/issue-274-branches-only-worktrees"
|
||||
WORKTREE = "branches/review-pr374-conflicts"
|
||||
|
||||
|
||||
def _full_remote_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Merge commit SHA": MERGE_SHA,
|
||||
"Cleanup status": "remote branch deleted",
|
||||
"Delete-branch capability resolved": "gitea.branch.delete allowed via delete_branch task",
|
||||
"Merged PR head branch": HEAD_BRANCH,
|
||||
"Deleted branch": HEAD_BRANCH,
|
||||
"Branch protection": "none",
|
||||
"Open PR inventory proof": "no other open PR references branch (inventory complete)",
|
||||
"Active heartbeat/claim/lease": "none",
|
||||
"Cleanup mutations": "gitea_delete_branch on remote head branch",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _full_worktree_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Cleanup status": "local worktree removed",
|
||||
"Removed worktree path": WORKTREE,
|
||||
"Pre-removal tracked state": "clean",
|
||||
"Pre-removal untracked state": "clean",
|
||||
"Git worktree list after removal": "only main checkout listed",
|
||||
"Cleanup mutations": "git worktree remove on session-owned review worktree",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TestPostMergeCleanupProof(unittest.TestCase):
|
||||
def test_no_cleanup_claim_passes(self):
|
||||
report = "## Controller Handoff\n- Task: review PR #1\n- Merge result: merged"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_missing_capability_proof_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Delete-branch capability resolved": "delete succeeded"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_unmerged_pr_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Merge result": "not merged"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("merge result" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_wrong_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Deleted branch": "feat/other-branch"}
|
||||
)
|
||||
report += "\nDeleted branch does not match merged PR head branch"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_protected_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Branch protection": "enabled"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_open_pr_reference_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Open PR inventory proof": "PR #999 still references branch"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_active_claim_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Active heartbeat/claim/lease": "status:in-progress on linked issue"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_dirty_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Pre-removal tracked state": "dirty"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("tracked" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_foreign_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Removed worktree path": "/tmp/foreign-worktree"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("branches/" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_cleanup_skipped_with_blocker_passes(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: gitea.branch.delete capability not resolved in active profile",
|
||||
])
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["outcome"], CLEANUP_SKIPPED)
|
||||
|
||||
def test_cleanup_skipped_without_blocker_blocked(self):
|
||||
report = "## Controller Handoff\n- Cleanup outcome: CLEANUP_SKIPPED"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_remote_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_remote_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["remote_delete_claimed"])
|
||||
|
||||
def test_full_worktree_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_worktree_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["worktree_remove_claimed"])
|
||||
|
||||
def test_validator_integration_blocks_unproven_delete(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Cleanup mutations: gitea_delete_branch deleted remote branch\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.post_merge_cleanup_proof", rule_ids)
|
||||
|
||||
def test_validator_integration_allows_skipped(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: branch still referenced by open PR #414",
|
||||
])
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
cleanup_findings = [
|
||||
f for f in result["findings"]
|
||||
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
|
||||
]
|
||||
self.assertEqual(cleanup_findings, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2346,6 +2346,7 @@ class TestWorkIssueFinalReport(unittest.TestCase):
|
||||
"- Safe next action: open PR",
|
||||
"- Next: open PR",
|
||||
"- Safety statement: no review/merge",
|
||||
"- Duplicate work outcome: duplicate work not prevented",
|
||||
])
|
||||
|
||||
def test_complete_work_issue_report_earns_a(self):
|
||||
|
||||
+9
-26
@@ -20,50 +20,33 @@ def run(script, *args):
|
||||
branch = arg
|
||||
break
|
||||
|
||||
lock_dir_ctx = None
|
||||
extra_env = os.environ.copy()
|
||||
lock_file = Path("/tmp/gitea_issue_lock.json")
|
||||
created_lock = False
|
||||
if script == "worktree-start" and branch:
|
||||
import re
|
||||
import tempfile
|
||||
import issue_lock_store
|
||||
|
||||
import json
|
||||
m = re.search(r"issue-(\d+)", branch)
|
||||
if not m:
|
||||
m = re.search(r"pr-(\d+)", branch)
|
||||
issue_num = int(m.group(1)) if m else 999
|
||||
lock_dir_ctx = tempfile.TemporaryDirectory()
|
||||
extra_env["GITEA_ISSUE_LOCK_DIR"] = lock_dir_ctx.name
|
||||
record = {
|
||||
lock_file.write_text(json.dumps({
|
||||
"issue_number": issue_num,
|
||||
"branch_name": branch,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"worktree_path": "/tmp/test-worktree",
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
path = issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=issue_num,
|
||||
lock_dir=lock_dir_ctx.name,
|
||||
)
|
||||
issue_lock_store.save_lock_file(path, record)
|
||||
"repo": "Gitea-Tools"
|
||||
}), encoding="utf-8")
|
||||
created_lock = True
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["bash", str(SCRIPTS / script), *args],
|
||||
capture_output=True, text=True, cwd=str(REPO),
|
||||
env=extra_env,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
finally:
|
||||
if lock_dir_ctx is not None:
|
||||
lock_dir_ctx.cleanup()
|
||||
if created_lock and lock_file.exists():
|
||||
lock_file.unlink()
|
||||
|
||||
|
||||
class TestWorktreeStart(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user