Compare commits

..
Author SHA1 Message Date
sysadmin 551ee7d70c fix: resolve conflicts for PR #421 2026-07-07 17:20:53 -04:00
sysadmin ddaf380db2 feat: gate reconciliation audit mode from unauthorized cleanup (Closes #419)
Add audit vs cleanup phase tracking so reconciliation audits stay read-only
unless cleanup is explicitly authorized with delete capability proof, safety
proof, and before/after snapshots. Block gitea_delete_branch and merged-cleanup
execution during audit phase, validate final reports for false no-mutations
claims, and document the boundary in the reconcile-landed-pr workflow.
2026-07-07 17:20:36 -04:00
sysadmin cc4741a4ce Merge pull request 'feat: block manual issue-lock seeding and require lock disclosure (Closes #447)' (#463) from feat/issue-447-lock-provenance into master 2026-07-07 15:46:09 -05:00
sysadmin d95ea323d7 Merge pull request 'feat: move duplicate-work detection before author mutations (Closes #400)' (#413) from feat/issue-400-duplicate-work-preflight into master 2026-07-07 15:40:23 -05:00
sysadminandClaude Opus 4.8 e4adccd82a fix: inject duplicate-work context fetcher for testable lock/create_pr paths (#400)
Expose issue_duplicate_context_fetcher on the MCP server so lock_issue,
commit_files, and create_pr duplicate rechecks avoid live Gitea calls in
unit tests. Update affected test suites to patch the fetcher without
weakening duplicate-work gate assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:33:31 -04:00
sysadminandClaude Opus 4.8 795f544047 feat: block manual issue-lock seeding and require lock disclosure (#447)
Add lock_provenance metadata on gitea_lock_issue writes and fail closed at
gitea_create_pr when provenance is missing. Final-report validation now
requires explicit External-state mutations disclosure for issue-lock
read/write/delete and blocks mixed author PR creation with reviewer approval.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:18:42 -04:00
sysadminandClaude Opus 4.8 0bad26230b fix: resolve conflicts for PR #413
Rebase onto current prgs/master and patch create_pr duplicate-gate test
with auth header mock to match post-rebase recheck path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 13:08:53 -04:00
sysadminandClaude Opus 4.8 cf057d7829 feat: move duplicate-work detection before author mutations (Closes #400)
Extract issue_work_duplicate_gate for open PR, remote branch, and active
claim checks; wire it into lock_issue, commit_files recheck, and create_pr
recheck. Add gitea_assess_work_issue_duplicate read-only preflight, work-issue
report outcome validation, and workflow section 10A.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 13:08:21 -04:00
29 changed files with 2177 additions and 1416 deletions
+344
View File
@@ -0,0 +1,344 @@
"""Audit vs cleanup phase gates for reconciliation workflows (#419).
Audit/reconciliation tasks are read-only unless a separate cleanup phase is
explicitly authorized with exact capability proof, safety proof, and
before/after snapshots. Cleanup mutations must be classified in final reports;
audit reports must not claim ``no mutations`` when cleanup occurred.
"""
from __future__ import annotations
import re
from typing import Any
RECONCILE_WORKFLOW_PATH = "workflows/reconcile-landed-pr.md"
PHASE_AUDIT = "audit"
PHASE_CLEANUP = "cleanup"
# Tasks that enter audit phase on capability resolution (read-only default).
AUDIT_PHASE_TASKS = frozenset({
"reconcile-landed-pr",
"reconcile_landed_pr",
"reconcile_issue_claims",
"reconcile_merged_cleanups",
})
# Mutation tasks forbidden during audit phase (fail closed).
AUDIT_FORBIDDEN_TASKS = frozenset({
"delete_branch",
"create_branch",
"push_branch",
"create_pr",
"commit_files",
"gitea_commit_files",
"mark_issue",
"lock_issue",
"claim_issue",
"close_pr",
"close_issue",
"create_issue",
"merge_pr",
"review_pr",
"submit_pr_review",
"comment_pr",
"comment_issue",
"set_issue_labels",
})
# Shell/git commands audit phase must not run.
AUDIT_FORBIDDEN_COMMAND_RE = re.compile(
r"(?:^|\s)(?:git\s+(?:push|branch\s+-D|worktree\s+remove)|"
r"gitea_delete_branch|delete_remote_branch)",
re.IGNORECASE,
)
_NO_MUTATIONS_RE = re.compile(
r"(?:no\s+mutations|mutations\s*:\s*none|no\s+unsafe\s+mutation)",
re.IGNORECASE,
)
_CLEANUP_OCCURRED_RE = re.compile(
r"(?:delete_remote_branch|remove_local_worktree|git\s+branch\s+-D|"
r"git\s+worktree\s+remove|remote branch.*deleted|worktree.*removed|"
r"cleanup\s+phase\s*:\s*(?!none\b)\S)",
re.IGNORECASE,
)
_EXTERNAL_STATE_RE = re.compile(
r"^\s*[-*]?\s*external[- ]state mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_GIT_REF_RE = re.compile(
r"^\s*[-*]?\s*git ref mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_CLEANUP_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*cleanup mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_CLEANUP_PHASE_AUTH_RE = re.compile(
r"^\s*[-*]?\s*cleanup phase (?:authorized|authorization)\s*:\s*true",
re.IGNORECASE | re.MULTILINE,
)
_DELETE_CAPABILITY_RE = re.compile(
r"^\s*[-*]?\s*delete.?branch capability(?: proven)?\s*:\s*true",
re.IGNORECASE | re.MULTILINE,
)
_BEFORE_AFTER_RE = re.compile(
r"^\s*[-*]?\s*before/after (?:state )?snapshot\s*:",
re.IGNORECASE | re.MULTILINE,
)
_SAFETY_PROOF_RE = re.compile(
r"^\s*[-*]?\s*(?:branch|worktree) safe to remove\s*:\s*true",
re.IGNORECASE | re.MULTILINE,
)
_session: dict[str, Any] | None = None
def _blank_session() -> dict[str, Any]:
return {
"phase": PHASE_AUDIT,
"entered_from_task": None,
"cleanup_authorized": False,
"cleanup_authorization": {},
}
def current_phase() -> str | None:
"""Return active reconciliation phase or None when unset."""
if not _session:
return None
return _session.get("phase")
def active_record() -> dict[str, Any] | None:
"""Return a copy of the session record, if any."""
return dict(_session) if _session else None
def clear_phase() -> None:
"""Clear reconciliation phase state."""
global _session
_session = None
def enter_audit_phase(task: str) -> dict[str, Any]:
"""Enter read-only audit phase for a reconciliation task."""
global _session
normalized = (task or "").strip().lower()
_session = _blank_session()
_session["entered_from_task"] = normalized
return dict(_session)
def authorize_cleanup_phase(
*,
operator_approved: bool = False,
workflow_authorized: bool = False,
delete_capability_proven: bool = False,
safety_proof: dict[str, Any] | None = None,
before_after_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Authorize cleanup phase after explicit approval and safety proofs."""
reasons: list[str] = []
if not (operator_approved or workflow_authorized):
reasons.append(
"cleanup phase requires operator approval or explicit workflow "
"authorization"
)
if not delete_capability_proven:
reasons.append(
"cleanup phase requires exact delete_branch capability proof "
"(gitea.branch.delete)"
)
safety = dict(safety_proof or {})
if not safety.get("safe_to_delete_remote") and not safety.get(
"safe_to_remove_worktree"
):
reasons.append(
"cleanup phase requires proof that branch/worktree is safe to remove"
)
snapshot = dict(before_after_snapshot or {})
if not snapshot.get("before") or not snapshot.get("after"):
reasons.append(
"cleanup phase requires before/after state snapshot"
)
if reasons:
return {
"authorized": False,
"phase": current_phase() or PHASE_AUDIT,
"reasons": reasons,
"safe_next_action": (
"remain in audit-only mode or supply operator approval, "
"delete_branch capability proof, safety proof, and "
"before/after snapshot before cleanup"
),
}
global _session
if _session is None:
_session = _blank_session()
_session["phase"] = PHASE_CLEANUP
_session["cleanup_authorized"] = True
_session["cleanup_authorization"] = {
"operator_approved": operator_approved,
"workflow_authorized": workflow_authorized,
"delete_capability_proven": delete_capability_proven,
"safety_proof": safety,
"before_after_snapshot": snapshot,
}
return {
"authorized": True,
"phase": PHASE_CLEANUP,
"reasons": [],
"cleanup_authorization": dict(_session["cleanup_authorization"]),
"safe_next_action": "proceed with authorized cleanup mutations only",
}
def check_audit_task_enters_phase(task: str) -> bool:
"""Return whether resolving *task* should enter audit phase."""
return (task or "").strip().lower() in AUDIT_PHASE_TASKS
def check_audit_mutation_allowed(task: str) -> tuple[bool, list[str]]:
"""Fail closed when a mutation task runs during audit phase."""
normalized = (task or "").strip().lower()
phase = current_phase()
if phase != PHASE_AUDIT:
return True, []
if normalized in AUDIT_FORBIDDEN_TASKS:
return False, [
f"task '{normalized}' is forbidden in audit-only reconciliation "
"mode: switch to an explicit cleanup phase with operator approval "
"and exact delete_branch capability proof before cleanup mutations"
]
return True, []
def check_cleanup_execution_allowed() -> tuple[bool, list[str]]:
"""Fail closed when cleanup execution is attempted without authorization."""
phase = current_phase()
if phase == PHASE_CLEANUP and (_session or {}).get("cleanup_authorized"):
return True, []
if phase is None:
return False, [
"cleanup execution requires an active reconciliation session; "
"resolve a reconciliation audit task first"
]
return False, [
"cleanup execution forbidden in audit-only reconciliation mode; "
"call gitea_authorize_reconciliation_cleanup_phase with operator "
"approval, delete_branch capability proof, safety proof, and "
"before/after snapshot"
]
def classify_cleanup_mutation(action: str) -> str:
"""Map a cleanup action to the required mutation ledger category (#419)."""
normalized = (action or "").strip().lower()
if "delete_remote" in normalized or normalized in {
"delete_branch",
"gitea_delete_branch",
}:
return "external-state"
if "branch" in normalized and "delete" in normalized:
return "git-ref"
if "worktree" in normalized or "remove_local" in normalized:
return "cleanup"
return "cleanup"
def assess_audit_reconciliation_report(report_text: str) -> dict[str, Any]:
"""Validate audit/cleanup reconciliation reports (fail closed)."""
text = report_text or ""
reasons: list[str] = []
cleanup_occurred = bool(_CLEANUP_OCCURRED_RE.search(text))
claims_no_mutations = bool(_NO_MUTATIONS_RE.search(text))
if cleanup_occurred and claims_no_mutations:
reasons.append(
"report claims no mutations but documents cleanup mutations; "
"audit-only reports must not perform cleanup and cleanup reports "
"must not claim no mutations"
)
if cleanup_occurred:
if not _CLEANUP_PHASE_AUTH_RE.search(text):
reasons.append(
"cleanup mutations reported without "
"'Cleanup phase authorized: true'"
)
if not _DELETE_CAPABILITY_RE.search(text):
reasons.append(
"cleanup mutations reported without delete_branch capability "
"proof"
)
if not _BEFORE_AFTER_RE.search(text):
reasons.append(
"cleanup mutations reported without before/after state snapshot"
)
if not _SAFETY_PROOF_RE.search(text):
reasons.append(
"cleanup mutations reported without branch/worktree safety proof"
)
if re.search(r"delete_remote|remote branch.*delet", text, re.I):
if not _EXTERNAL_STATE_RE.search(text):
reasons.append(
"remote branch deletion must be classified under "
"External-state mutations"
)
if re.search(r"git\s+branch\s+-D|local branch.*delet", text, re.I):
if not _GIT_REF_RE.search(text):
reasons.append(
"local branch deletion must be classified under "
"Git ref mutations"
)
if re.search(r"worktree.*remov|remove_local_worktree", text, re.I):
if not _CLEANUP_MUTATIONS_RE.search(text):
reasons.append(
"worktree removal must be classified under Cleanup mutations"
)
if (
RECONCILE_WORKFLOW_PATH.replace("workflows/", "") in text
or "reconcile-landed-pr" in text.lower()
):
if cleanup_occurred and "audit phase" in text.lower():
if "cleanup phase" not in text.lower():
reasons.append(
"report mixes audit phase with cleanup mutations without "
"documenting cleanup phase transition"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"cleanup_occurred": cleanup_occurred,
"claims_no_mutations": claims_no_mutations,
"safe_next_action": (
"proceed"
if proven
else "fix audit/cleanup report: separate audit from cleanup phase, "
"classify mutations, and do not claim no mutations after cleanup"
),
}
def assess_audit_command_allowed(command: str) -> tuple[bool, list[str]]:
"""Block shell commands that perform cleanup during audit phase."""
phase = current_phase()
if phase != PHASE_AUDIT:
return True, []
cmd = (command or "").strip()
if AUDIT_FORBIDDEN_COMMAND_RE.search(cmd):
return False, [
f"command forbidden in audit-only reconciliation mode: {cmd!r}; "
"authorize cleanup phase before branch/worktree deletion or push"
]
return True, []
+19 -18
View File
@@ -274,26 +274,27 @@ is proven abandoned and the takeover is recorded.
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue` mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
records an `author_issue_work` lease in a keyed lock file under records an `author_issue_work` lease in the issue-lock payload with issue
`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file number, optional PR number, branch, worktree path, claimant identity/profile,
per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds created timestamp, expiry timestamp, and last heartbeat timestamp. An active
its active lock through a per-process pointer so concurrent repos/issues never same-issue/same-operation lease blocks duplicate work. An expired lease still
share one overwrite-prone slot (#443). 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 **Issue-lock recovery (#447):** Do not manually seed, restore, or delete
path, claimant identity/profile, created timestamp, expiry timestamp, and last `/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate shared state and manual writes can clobber another session's live lease. Use
work. An expired lease still blocks takeover until a recovery review records why `sanctioned recovery` instead:
the prior work is abandoned, completed, or unsafe to continue.
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal 1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
recovery path.** That global slot is deprecated and can clobber unrelated live 2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch 3. Operator override only when explicitly authorized — record
adoption rebinds the session when the issue's exact branch already exists (#442). `External-state mutations` and `operator override proof` in the final report.
`gitea_create_pr` resolves the durable keyed lock by session pointer or by
matching `head` branch without unsafe manual seeding (#440). Branch ownership `gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
uses structured ``(fix|feat|docs|chore)/issue-<n>-`` parsing — not broad metadata. Final-report validation blocks handoffs that hide lock read/write/delete
substring matches like ``issue-420`` inside ``issue-4200``. 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 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 the recovery review proves the branch is abandoned or superseded. Never delete
+79
View File
@@ -11,6 +11,7 @@ import inspect
import re import re
from typing import Any, Callable from typing import Any, Callable
import issue_lock_provenance
from review_proofs import ( from review_proofs import (
HANDOFF_HEADING, HANDOFF_HEADING,
assess_controller_handoff, assess_controller_handoff,
@@ -870,6 +871,70 @@ 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_audit_reconciliation_boundary(report_text: str) -> list[dict[str, str]]:
from audit_reconciliation_mode import assess_audit_reconciliation_report
result = assess_audit_reconciliation_report(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"reconcile.audit_cleanup_boundary",
result.get("reasons") or [],
field="Audit/cleanup phase",
severity="block",
safe_next_action=result.get("safe_next_action")
or "separate audit from authorized cleanup and classify mutations",
)
def _rule_reviewer_review_mutation( def _rule_reviewer_review_mutation(
report_text: str, report_text: str,
*, *,
@@ -889,10 +954,17 @@ def _rule_reviewer_review_mutation(
) )
_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]]]]] = { _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [ "review_pr": [
_rule_shared_controller_handoff, _rule_shared_controller_handoff,
_rule_shared_email_disclosure, _rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations, _rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none, _rule_reviewer_vague_mutations_none,
_rule_reviewer_mutation_categories, _rule_reviewer_mutation_categories,
@@ -913,6 +985,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"reconcile_already_landed": [ "reconcile_already_landed": [
_rule_reconcile_controller_handoff, _rule_reconcile_controller_handoff,
_rule_shared_email_disclosure, _rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_stale_author_fields, _rule_reconcile_stale_author_fields,
_rule_reconcile_eligible_reviewed, _rule_reconcile_eligible_reviewed,
_rule_reconcile_linked_issue_live, _rule_reconcile_linked_issue_live,
@@ -920,29 +993,35 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_git_fetch_readonly, _rule_reviewer_git_fetch_readonly,
_rule_reviewer_legacy_workspace_mutations, _rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none, _rule_reviewer_vague_mutations_none,
_rule_audit_reconciliation_boundary,
], ],
"author_issue": [ "author_issue": [
_rule_shared_controller_handoff, _rule_shared_controller_handoff,
_rule_shared_email_disclosure, _rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none, _rule_reviewer_vague_mutations_none,
], ],
"work_issue": [ "work_issue": [
_rule_shared_controller_handoff, _rule_shared_controller_handoff,
_rule_shared_email_disclosure, _rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none, _rule_reviewer_vague_mutations_none,
], ],
"issue_filing": [ "issue_filing": [
_rule_shared_controller_handoff, _rule_shared_controller_handoff,
_rule_shared_email_disclosure, _rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
], ],
"inventory": [ "inventory": [
_rule_shared_controller_handoff, _rule_shared_controller_handoff,
_rule_shared_email_disclosure, _rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_pagination_proof, _rule_reconcile_pagination_proof,
], ],
"issue_selection": [ "issue_selection": [
_rule_shared_controller_handoff, _rule_shared_controller_handoff,
_rule_shared_email_disclosure, _rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
], ],
} }
+307 -140
View File
@@ -537,23 +537,22 @@ import role_namespace_gate # noqa: E402
import task_capability_map # noqa: E402 import task_capability_map # noqa: E402
import review_proofs # noqa: E402 import review_proofs # noqa: E402
import agent_temp_artifacts import agent_temp_artifacts
import issue_branch_ownership # noqa: E402
import issue_lock_worktree # noqa: E402 import issue_lock_worktree # noqa: E402
import issue_lock_store # noqa: E402 import issue_lock_provenance # noqa: E402
import issue_lock_adoption # noqa: E402
import already_landed_reconcile # noqa: E402 import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402 import author_mutation_worktree # noqa: E402
import issue_claim_heartbeat # noqa: E402 import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
import merged_cleanup_reconcile # noqa: E402 import merged_cleanup_reconcile # noqa: E402
import reconciler_profile # noqa: E402 import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402 import reconciliation_workflow # noqa: E402
import audit_reconciliation_mode # noqa: E402
import review_merge_state_machine # noqa: E402 import review_merge_state_machine # noqa: E402
import native_mcp_preference # noqa: E402 import native_mcp_preference # noqa: E402
# Keyed issue-lock storage (#443): per remote/org/repo/issue files under # Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
# GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer. # consumed by gitea_create_pr and scripts/worktree-start.
# Legacy global path retained only for test/doc references — do not seed manually.
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json" ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
WORK_LEASE_TTL_HOURS = 4 WORK_LEASE_TTL_HOURS = 4
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work" AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
@@ -585,59 +584,15 @@ def _parse_work_lease_timestamp(value: str | None) -> datetime | None:
return None return None
def _load_existing_issue_lock( def _load_existing_issue_lock() -> dict | None:
*, if not os.path.exists(ISSUE_LOCK_FILE):
remote: str | None = None, return 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)
try: try:
return issue_lock_store.bind_session_lock(data) with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
except Exception as e: data = json.load(f)
raise RuntimeError(f"Could not write issue lock file: {e}") from e return data if isinstance(data, dict) else None
except Exception:
return None
def _work_lease_claimant(host: str | None) -> dict: def _work_lease_claimant(host: str | None) -> dict:
@@ -727,17 +682,117 @@ def _branch_entry_name(branch: dict | str) -> str:
return str(branch.get("name") or branch.get("ref") or "") return str(branch.get("name") or branch.get("ref") or "")
def _branch_entry_commit_sha(branch: dict | str) -> str | None: def _live_fetch_issue_duplicate_context(
"""Best-effort head SHA for a Gitea branch entry (None when absent).""" h: str,
if not isinstance(branch, dict): 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 return None
commit = branch.get("commit") issue_number = int(lock_data.get("issue_number") or 0)
if isinstance(commit, dict): locked_branch = lock_data.get("branch_name")
sha = commit.get("id") or commit.get("sha") if not issue_number:
if sha: return None
return str(sha) h, o, r = _resolve(
sha = branch.get("commit_sha") remote or lock_data.get("remote") or "dadeschools",
return str(sha) if sha else None 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: def _reveal_endpoints() -> bool:
@@ -1187,9 +1242,8 @@ def gitea_lock_issue(
resolved_worktree = issue_lock_worktree.resolve_author_worktree_path( resolved_worktree = issue_lock_worktree.resolve_author_worktree_path(
worktree_path, PROJECT_ROOT worktree_path, PROJECT_ROOT
) )
h, o, r = _resolve(remote, host, org, repo) active_lease_block = _active_work_lease_block(
active_lease_block = issue_lock_store.assess_same_issue_lease_conflict( _load_existing_issue_lock(),
_load_existing_issue_lock(remote=remote, org=o, repo=r, issue_number=issue_number),
issue_number=issue_number, issue_number=issue_number,
branch_name=branch_name, branch_name=branch_name,
worktree_path=resolved_worktree, worktree_path=resolved_worktree,
@@ -1213,58 +1267,21 @@ def gitea_lock_issue(
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment) 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) auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls?state=open" duplicate_gate = _assess_issue_duplicate_gate(
issue_number,
try: h=h,
prs = api_get_all(url, auth) o=o,
except Exception as e: r=r,
raise RuntimeError(f"Could not list open PRs to verify issue lock: {e}") auth=auth,
locked_branch=branch_name,
for pr in prs: phase=issue_work_duplicate_gate.PHASE_LOCK,
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,
) )
if adoption["block"]: if duplicate_gate.get("block"):
competing = ", ".join(adoption["competing_branches"]) raise ValueError("; ".join(duplicate_gate.get("reasons") or [
raise ValueError( f"duplicate work gate blocked issue #{issue_number} (fail closed)"
f"Issue #{issue_number} already has matching branch '{competing}' " ]))
"that is not the requested branch (fail closed)"
)
work_lease = _build_author_issue_work_lease( work_lease = _build_author_issue_work_lease(
issue_number=issue_number, issue_number=issue_number,
@@ -1280,9 +1297,17 @@ def gitea_lock_issue(
"repo": r, "repo": r,
"worktree_path": resolved_worktree, "worktree_path": resolved_worktree,
"work_lease": work_lease, "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( agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(
git_state.get("porcelain_status") or "" git_state.get("porcelain_status") or ""
@@ -1297,22 +1322,7 @@ def gitea_lock_issue(
"branch_name": branch_name, "branch_name": branch_name,
"worktree_path": resolved_worktree, "worktree_path": resolved_worktree,
"work_lease": work_lease, "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: if agent_artifacts:
result["warnings"] = [ result["warnings"] = [
"Agent temp artifacts at repo root (delete before implementation): " "Agent temp artifacts at repo root (delete before implementation): "
@@ -1321,6 +1331,39 @@ def gitea_lock_issue(
return result 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() @mcp.tool()
def gitea_create_pr( def gitea_create_pr(
title: str, title: str,
@@ -1371,8 +1414,23 @@ def gitea_create_pr(
verify_preflight_purity(remote, worktree_path=worktree_path) verify_preflight_purity(remote, worktree_path=worktree_path)
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
# ── Issue Lock Validation (Issue #194 / #196 / #443) ── # ── Issue Lock Validation (Issue #194 / #196) ──
lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head) 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_issue = lock_data.get("issue_number")
locked_branch = lock_data.get("branch_name") locked_branch = lock_data.get("branch_name")
@@ -1405,6 +1463,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)" 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) auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls" url = f"{repo_api_url(h, o, r)}/pulls"
payload = {"title": title, "body": body, "head": head, "base": base} payload = {"title": title, "body": body, "head": head, "base": base}
@@ -2868,7 +2941,13 @@ def _prepare_commit_payload_files(files: list[dict]) -> tuple[list[dict], list[d
processed_files = [] processed_files = []
source_proofs = [] 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") locked_worktree = lock_data.get("worktree_path")
if locked_worktree: if locked_worktree:
@@ -3003,6 +3082,20 @@ def gitea_commit_files(
if blocked: if blocked:
return 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) verify_preflight_purity(remote)
processed_files, source_proofs = _prepare_commit_payload_files(files) processed_files, source_proofs = _prepare_commit_payload_files(files)
@@ -3588,6 +3681,18 @@ def gitea_delete_branch(
"permission_report": _permission_block_report("gitea.branch.delete"), "permission_report": _permission_block_report("gitea.branch.delete"),
} }
audit_allowed, audit_reasons = (
audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch")
)
if not audit_allowed:
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": audit_reasons,
"audit_phase": audit_reconciliation_mode.current_phase(),
}
verify_preflight_purity(remote) verify_preflight_purity(remote)
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
@@ -3657,6 +3762,18 @@ def gitea_reconcile_merged_cleanups(
"execute_confirmed must be True when dry_run=False (fail closed)" "execute_confirmed must be True when dry_run=False (fail closed)"
) )
if not dry_run:
exec_allowed, exec_reasons = (
audit_reconciliation_mode.check_cleanup_execution_allowed()
)
if not exec_allowed:
return {
"success": False,
"performed": False,
"reasons": exec_reasons,
"audit_phase": audit_reconciliation_mode.current_phase(),
}
h, o, r = _resolve(remote, host, org, repo) h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h) auth = _auth(h)
base = repo_api_url(h, o, r) base = repo_api_url(h, o, r)
@@ -3739,6 +3856,53 @@ def gitea_reconcile_merged_cleanups(
return {"success": True, "performed": True, **report} return {"success": True, "performed": True, **report}
@mcp.tool()
def gitea_authorize_reconciliation_cleanup_phase(
operator_approved: bool = False,
workflow_authorized: bool = False,
delete_capability_proven: bool = False,
safe_to_delete_remote: bool = False,
safe_to_remove_worktree: bool = False,
before_state: str = "",
after_state: str = "",
) -> dict:
"""Authorize cleanup phase after audit-only reconciliation (#419).
Requires operator or workflow approval, exact delete_branch capability proof,
branch/worktree safety proof, and before/after state snapshots. Audit phase
forbids branch deletion, worktree removal, pushes, and issue/PR mutations.
"""
delete_gate = _profile_operation_gate("gitea.branch.delete")
capability_ok = not bool(delete_gate)
if delete_capability_proven and delete_gate:
return {
"authorized": False,
"performed": False,
"delete_capability_verified": False,
"reasons": [
"delete_capability_proven=true but active profile lacks "
"gitea.branch.delete",
] + delete_gate,
"audit_phase": audit_reconciliation_mode.current_phase(),
}
result = audit_reconciliation_mode.authorize_cleanup_phase(
operator_approved=operator_approved,
workflow_authorized=workflow_authorized,
delete_capability_proven=delete_capability_proven and capability_ok,
safety_proof={
"safe_to_delete_remote": safe_to_delete_remote,
"safe_to_remove_worktree": safe_to_remove_worktree,
},
before_after_snapshot={
"before": (before_state or "").strip(),
"after": (after_state or "").strip(),
},
)
result["performed"] = bool(result.get("authorized"))
result["delete_capability_verified"] = capability_ok
return result
@mcp.tool() @mcp.tool()
def gitea_assess_already_landed_reconciliation( def gitea_assess_already_landed_reconciliation(
pr_number: int, pr_number: int,
@@ -6845,6 +7009,9 @@ def gitea_resolve_task_capability(
if reason_msg: if reason_msg:
result["reason"] = reason_msg result["reason"] = reason_msg
role_session_router.sync_route_from_capability(result) role_session_router.sync_route_from_capability(result)
if audit_reconciliation_mode.check_audit_task_enters_phase(task):
phase_record = audit_reconciliation_mode.enter_audit_phase(task)
result["reconciliation_phase"] = phase_record.get("phase")
was_terminal = capability_stop_terminal.is_active() was_terminal = capability_stop_terminal.is_active()
terminal = capability_stop_terminal.sync_from_capability_result(result) terminal = capability_stop_terminal.sync_from_capability_result(result)
if terminal: if terminal:
-31
View File
@@ -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
-117
View File
@@ -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,
}
+269
View File
@@ -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"
],
}
-385
View File
@@ -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
+180
View File
@@ -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
View File
@@ -13,9 +13,9 @@ import subprocess
from typing import Any from typing import Any
from reviewer_worktree import parse_dirty_tracked_files from reviewer_worktree import parse_dirty_tracked_files
import issue_lock_store
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) 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) 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: def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
if path: lock_path = (path or ISSUE_LOCK_FILE).strip()
return issue_lock_store.read_lock_file(path.strip()) if not lock_path or not os.path.exists(lock_path):
return issue_lock_store.read_session_issue_lock() 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: def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
if lock_path: lock = read_issue_lock(lock_path)
lock = issue_lock_store.read_lock_file(lock_path.strip()) if not lock:
if not lock: return False
return False return (lock.get("branch_name") or "").strip() == (branch or "").strip()
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
return issue_lock_store.has_active_issue_lock(branch)
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]: def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
+12
View File
@@ -3624,9 +3624,12 @@ def assess_work_issue_mode_isolation(report_text: str) -> dict:
def assess_work_issue_final_report(report_text: str) -> dict: def assess_work_issue_final_report(report_text: str) -> dict:
"""#139: composite verifier for work-issue final reports.""" """#139: composite verifier for work-issue final reports."""
from issue_work_duplicate_gate import assess_work_issue_duplicate_report
checks = { checks = {
"workflow_source": assess_work_issue_workflow_source(report_text), "workflow_source": assess_work_issue_workflow_source(report_text),
"mode_isolation": assess_work_issue_mode_isolation(report_text), "mode_isolation": assess_work_issue_mode_isolation(report_text),
"duplicate_work_outcome": assess_work_issue_duplicate_report(report_text),
} }
reasons = [] reasons = []
@@ -5112,6 +5115,15 @@ def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
return _assess(report_text or "") return _assess(report_text or "")
def assess_audit_reconciliation_report(report_text: str | None) -> dict:
"""#419: validate audit vs cleanup reconciliation report boundaries."""
from audit_reconciliation_mode import (
assess_audit_reconciliation_report as _assess,
)
return _assess(report_text or "")
_GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I) _GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I)
_NOT_APPLICABLE_VALUE = re.compile( _NOT_APPLICABLE_VALUE = re.compile(
+5 -11
View File
@@ -38,21 +38,13 @@ fi
branch="$1" branch="$1"
start_ref="${2:-prgs/master}" 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). # Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
if [[ "$allow_unlinked" -eq 0 ]]; then if [[ "$allow_unlinked" -eq 0 ]]; then
locked_branch=$(python3 -c " if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
import sys echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
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
exit 2 exit 2
fi fi
locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
if [[ "$branch" != "$locked_branch" ]]; then if [[ "$branch" != "$locked_branch" ]]; then
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2 echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
exit 2 exit 2
@@ -76,6 +68,8 @@ EOF
fi fi
fi fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
worktree_name="${branch//\//-}" worktree_name="${branch//\//-}"
worktree_path="$repo_root/branches/$worktree_name" worktree_path="$repo_root/branches/$worktree_name"
@@ -304,6 +304,40 @@ If any required mutation capability is missing:
* include safe next action (profile switch, human close, or dedicated reconciler * include safe next action (profile switch, human close, or dedicated reconciler
profile) profile)
## 15A. Audit vs cleanup phase (#419)
Reconciliation audits are **read-only** unless a separate cleanup phase is
explicitly authorized.
**Audit phase forbids** (``audit_reconciliation_mode.check_audit_mutation_allowed``
fails closed):
* ``gitea_delete_branch``
* ``git branch -D``
* ``git worktree remove``
* pushes
* issue/PR mutations
* file edits
Dry-run merged-cleanup reconciliation (``gitea_reconcile_merged_cleanups`` with
``dry_run=True``) stays in audit phase. Execution requires:
1. Operator approval or workflow authorization
2. Exact ``delete_branch`` capability proof (``gitea.branch.delete``)
3. Proof branch/worktree is safe to remove
4. Before/after state snapshot
Call ``gitea_authorize_reconciliation_cleanup_phase`` before any cleanup
mutation. Final reports must not claim ``no mutations`` if cleanup occurred.
Classify cleanup mutations as:
* remote branch deletion → **External-state mutations**
* local branch deletion → **Git ref mutations**
* worktree removal → **Cleanup mutations**
``audit_reconciliation_mode.assess_audit_reconciliation_report`` validates
these boundaries in final reports.
## 16. Mutation classification ## 16. Mutation classification
Use precise mutation categories in the final report: Use precise mutation categories in the final report:
@@ -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. 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 ## 11. Claim or lock the issue before implementation
Claim/lock the issue before implementation if the project provides a claim/lock mechanism. 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. 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. 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: Report:
@@ -726,6 +747,12 @@ Use only precise categories:
* External-state mutations: * External-state mutations:
* Read-only diagnostics: * 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. `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`. If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
+4
View File
@@ -104,6 +104,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.read", "permission": "gitea.read",
"role": "author", "role": "author",
}, },
"reconciliation_cleanup": {
"permission": "gitea.branch.delete",
"role": "author",
},
"work_issue": { "work_issue": {
"permission": "gitea.pr.create", "permission": "gitea.pr.create",
"role": "author", "role": "author",
+7 -6
View File
@@ -74,20 +74,21 @@ ISSUE_WRITE_ENV = {
class TestIssueLockArtifactWarning(unittest.TestCase): class TestIssueLockArtifactWarning(unittest.TestCase):
def setUp(self): def setUp(self):
self._lock_dir = tempfile.TemporaryDirectory() self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
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.start() self._env_patcher.start()
def tearDown(self): def tearDown(self):
self._env_patcher.stop() 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._auth", return_value="token x")
@patch("mcp_server._resolve", return_value=("h", "o", "r")) @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") @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 = { mock_state.return_value = {
"current_branch": "master", "current_branch": "master",
"porcelain_status": "?? _emit_payload.py\n", "porcelain_status": "?? _emit_payload.py\n",
+291
View File
@@ -0,0 +1,291 @@
"""Tests for audit vs cleanup reconciliation mode (#419)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import audit_reconciliation_mode as arm
import mcp_server
from audit_reconciliation_mode import (
AUDIT_FORBIDDEN_TASKS,
PHASE_AUDIT,
PHASE_CLEANUP,
assess_audit_command_allowed,
assess_audit_reconciliation_report,
authorize_cleanup_phase,
check_audit_mutation_allowed,
check_cleanup_execution_allowed,
classify_cleanup_mutation,
clear_phase,
enter_audit_phase,
)
from final_report_validator import assess_final_report_validator
from review_proofs import assess_audit_reconciliation_report as proofs_assess
from task_capability_map import required_permission, required_role
DELETE_PROFILE = {
"profile_name": "prgs-author-delete",
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
"forbidden_operations": [],
"audit_label": "prgs-author-delete",
}
READ_PROFILE = {
"profile_name": "prgs-author",
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
"forbidden_operations": ["gitea.branch.delete"],
"audit_label": "prgs-author",
}
READ_ENV = {
"GITEA_MCP_CONFIG": os.path.join(
os.path.dirname(__file__), "..", "profiles.json"
),
"GITEA_MCP_PROFILE": "prgs-author",
}
def _authorize_cleanup(**kwargs):
defaults = {
"operator_approved": True,
"delete_capability_proven": True,
"safety_proof": {"safe_to_delete_remote": True},
"before_after_snapshot": {
"before": "remote branch exists",
"after": "remote branch absent",
},
}
defaults.update(kwargs)
return authorize_cleanup_phase(**defaults)
def _cleanup_report(**overrides):
base = (
"Task mode: reconcile-landed-pr\n"
"Workflow source: workflows/reconcile-landed-pr.md\n"
"Audit phase: read-only assessment\n"
"Cleanup phase authorized: true\n"
"Delete-branch capability proven: true\n"
"Branch safe to remove: true\n"
"Before/after state snapshot: remote branch feat/x present → absent\n"
"External-state mutations: deleted remote branch feat/x\n"
"Git ref mutations: none\n"
"Cleanup mutations: removed worktree branches/feat-x\n"
)
for key, value in overrides.items():
base = base.replace(key, value)
return base
class TestAuditPhaseGates(unittest.TestCase):
def setUp(self):
clear_phase()
enter_audit_phase("reconcile-landed-pr")
def tearDown(self):
clear_phase()
def test_audit_blocks_delete_branch_task(self):
allowed, reasons = check_audit_mutation_allowed("delete_branch")
self.assertFalse(allowed)
self.assertTrue(reasons)
def test_audit_blocks_worktree_shell_commands(self):
allowed, reasons = assess_audit_command_allowed(
"git worktree remove branches/feat-x"
)
self.assertFalse(allowed)
self.assertTrue(reasons)
def test_audit_blocks_local_branch_delete_command(self):
allowed, reasons = assess_audit_command_allowed("git branch -D feat/x")
self.assertFalse(allowed)
def test_audit_allows_read_tasks(self):
allowed, _ = check_audit_mutation_allowed("reconcile_landed_pr")
self.assertTrue(allowed)
def test_forbidden_set_covers_issue_and_pr_mutations(self):
for task in ("close_pr", "comment_issue", "commit_files", "push_branch"):
self.assertIn(task, AUDIT_FORBIDDEN_TASKS)
class TestCleanupAuthorization(unittest.TestCase):
def setUp(self):
clear_phase()
enter_audit_phase("reconcile_merged_cleanups")
def tearDown(self):
clear_phase()
def test_cleanup_without_approval_blocked(self):
result = authorize_cleanup_phase(
delete_capability_proven=True,
safety_proof={"safe_to_delete_remote": True},
before_after_snapshot={"before": "a", "after": "b"},
)
self.assertFalse(result["authorized"])
def test_cleanup_without_capability_proof_blocked(self):
result = _authorize_cleanup(delete_capability_proven=False)
self.assertFalse(result["authorized"])
def test_cleanup_without_snapshot_blocked(self):
result = _authorize_cleanup(before_after_snapshot={"before": "", "after": ""})
self.assertFalse(result["authorized"])
def test_authorized_cleanup_switches_phase(self):
result = _authorize_cleanup()
self.assertTrue(result["authorized"])
self.assertEqual(result["phase"], PHASE_CLEANUP)
def test_cleanup_execution_allowed_only_after_authorization(self):
self.assertFalse(check_cleanup_execution_allowed()[0])
_authorize_cleanup()
self.assertTrue(check_cleanup_execution_allowed()[0])
class TestReportVerifier(unittest.TestCase):
def test_false_no_mutations_after_cleanup_blocked(self):
report = (
"Task mode: reconcile-landed-pr\n"
"No mutations performed.\n"
"delete_remote_branch feat/dup\n"
)
result = assess_audit_reconciliation_report(report)
self.assertFalse(result["proven"])
self.assertIn("no mutations", result["reasons"][0].lower())
def test_cleanup_without_authorization_fields_blocked(self):
report = (
"Task mode: reconcile-landed-pr\n"
"remove_local_worktree branches/feat-x\n"
)
result = assess_audit_reconciliation_report(report)
self.assertFalse(result["proven"])
def test_authorized_cleanup_report_passes(self):
result = assess_audit_reconciliation_report(_cleanup_report())
self.assertTrue(result["proven"])
def test_mutation_classification_enforced(self):
report = (
"Task mode: reconcile-landed-pr\n"
"Cleanup phase authorized: true\n"
"Delete-branch capability proven: true\n"
"Branch safe to remove: true\n"
"Before/after state snapshot: present\n"
"remove_local_worktree branches/feat-x\n"
)
result = assess_audit_reconciliation_report(report)
self.assertFalse(result["proven"])
def test_proofs_export_matches_module(self):
report = "No mutations performed.\ndelete_remote_branch feat/dup"
self.assertEqual(
proofs_assess(report)["proven"],
assess_audit_reconciliation_report(report)["proven"],
)
def test_final_report_validator_includes_boundary_rule(self):
report = "No mutations performed.\ndelete_remote_branch feat/dup"
result = assess_final_report_validator(
report_text=report,
task_kind="reconcile_already_landed",
)
self.assertTrue(result["blocked"])
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reconcile.audit_cleanup_boundary", rule_ids)
class TestMutationClassification(unittest.TestCase):
def test_remote_delete_is_external_state(self):
self.assertEqual(
classify_cleanup_mutation("delete_remote_branch"),
"external-state",
)
def test_worktree_remove_is_cleanup(self):
self.assertEqual(
classify_cleanup_mutation("remove_local_worktree"),
"cleanup",
)
class TestMcpGates(unittest.TestCase):
def setUp(self):
clear_phase()
enter_audit_phase("reconcile_merged_cleanups")
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch(
"mcp_server.get_auth_header", return_value="token test"
).start()
def tearDown(self):
patch.stopall()
clear_phase()
mcp_server._IDENTITY_CACHE.clear()
@patch.dict(os.environ, READ_ENV, clear=True)
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
def test_delete_branch_blocked_in_audit_phase(self, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
self.assertFalse(result["success"])
self.assertEqual(result["audit_phase"], PHASE_AUDIT)
self.mock_api.assert_not_called()
@patch.dict(os.environ, READ_ENV, clear=True)
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
def test_reconcile_execute_blocked_without_cleanup_auth(self, _profile):
with self.assertRaises(ValueError):
mcp_server.gitea_reconcile_merged_cleanups(
dry_run=False,
execute_confirmed=False,
remote="prgs",
)
@patch.dict(os.environ, READ_ENV, clear=True)
@patch("mcp_server.get_profile", return_value=READ_PROFILE)
def test_cleanup_auth_fails_without_delete_capability(self, _profile):
result = mcp_server.gitea_authorize_reconciliation_cleanup_phase(
operator_approved=True,
delete_capability_proven=True,
safe_to_delete_remote=True,
before_state="exists",
after_state="gone",
)
self.assertFalse(result["authorized"])
self.assertFalse(result["delete_capability_verified"])
@patch.dict(os.environ, READ_ENV, clear=True)
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
def test_delete_branch_allowed_after_cleanup_authorization(self, _profile):
mcp_server.gitea_authorize_reconciliation_cleanup_phase(
operator_approved=True,
delete_capability_proven=True,
safe_to_delete_remote=True,
before_state="exists",
after_state="gone",
)
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
self.mock_api.return_value = {}
result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs")
self.assertTrue(result["success"])
class TestTaskCapabilityMap(unittest.TestCase):
def test_reconciliation_cleanup_maps_delete_permission(self):
self.assertEqual(
required_permission("reconciliation_cleanup"),
"gitea.branch.delete",
)
self.assertEqual(required_role("reconciliation_cleanup"), "author")
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -76,7 +76,14 @@ class CommitFilesCapabilityBase(unittest.TestCase):
with open(self.config_path, "w", encoding="utf-8") as fh: with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG)) 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): def tearDown(self):
self._dup_fetcher_patcher.stop()
self._remotes.stop() self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear() mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = ( mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
+25 -10
View File
@@ -66,10 +66,16 @@ class TestCommitPayloads(unittest.TestCase):
) )
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name) 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() work_lease = {
os.environ["GITEA_ISSUE_LOCK_DIR"] = self._lock_dir.name "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 = { self.lock_data = {
"issue_number": 263, "issue_number": 263,
"branch_name": "feat/issue-263-native-commit-payloads", "branch_name": "feat/issue-263-native-commit-payloads",
@@ -77,12 +83,14 @@ class TestCommitPayloads(unittest.TestCase):
"org": "Example-Org", "org": "Example-Org",
"repo": "Example-Repo", "repo": "Example-Repo",
"worktree_path": self.locked_worktree_path, "worktree_path": self.locked_worktree_path,
"work_lease": { "work_lease": work_lease,
"operation_type": "author_issue_work", "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
"expires_at": "2999-01-01T00:00:00Z", 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 # Reset preflight status to bypass/pass verification in tests
self.orig_whoami_called = mcp_server._preflight_whoami_called 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_whoami_called = True
mcp_server._preflight_capability_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): def tearDown(self):
self._dup_fetcher_patcher.stop()
self._remotes.stop() self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear() mcp_server._IDENTITY_CACHE.clear()
@@ -99,7 +114,8 @@ class TestCommitPayloads(unittest.TestCase):
self._dir.cleanup() self._dir.cleanup()
self.locked_worktree_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: def _env(self, profile: str) -> dict:
return { return {
@@ -108,7 +124,6 @@ class TestCommitPayloads(unittest.TestCase):
"GITEA_TOKEN_AUTHOR": "author-pass", "GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TEST_PORCELAIN": "", "GITEA_TEST_PORCELAIN": "",
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path, "GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
} }
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
-35
View File
@@ -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()
-76
View File
@@ -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()
+130
View File
@@ -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()
-152
View File
@@ -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()
-181
View File
@@ -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()
+251
View File
@@ -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()
+8
View File
@@ -87,6 +87,14 @@ def test_reconcile_landed_workflow_contract():
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
assert "RECOVERY_HANDOFF_ONLY" in text assert "RECOVERY_HANDOFF_ONLY" in text
assert "resolve_partial_reconciliation_plan" in text assert "resolve_partial_reconciliation_plan" in text
assert "check_audit_mutation_allowed" in text
assert "gitea_authorize_reconciliation_cleanup_phase" in text
def test_audit_reconciliation_verifier_exported():
from review_proofs import assess_audit_reconciliation_report
assert callable(assess_audit_reconciliation_report)
def test_create_issue_workflow_contract(): def test_create_issue_workflow_contract():
+145 -209
View File
@@ -6,7 +6,6 @@ the MCP protocol) with mocked API responses.
import json import json
import os import os
import sys import sys
import tempfile
import unittest import unittest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@@ -46,7 +45,6 @@ from gitea_auth import get_profile # noqa: E402
import gitea_config # noqa: E402 import gitea_config # noqa: E402
import mcp_server import mcp_server
import issue_lock_store
FAKE_AUTH = "Basic dGVzdDp0ZXN0" 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): 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 = { record = {
"issue_number": issue_number, "issue_number": issue_number,
"branch_name": branch_name, "branch_name": branch_name,
"remote": "dadeschools", "remote": "dadeschools",
"org": "Scaled-Tech-Consulting", "org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools", "repo": "Gitea-Tools",
"worktree_path": "/tmp/test-worktree", "work_lease": work_lease,
"work_lease": { "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
"operation_type": "author_issue_work", tool="gitea_lock_issue",
"expires_at": "2999-01-01T00:00:00Z", claimant=work_lease.get("claimant"),
}, ),
} }
record.update(overrides) record.update(overrides)
return record return record
def _bind_test_lock(**overrides) -> str: def _clear_duplicate_context_fetcher(*_args, **_kwargs):
remote = overrides.get("remote", "dadeschools") """Default injectable duplicate-work context for lock/create_pr tests."""
record = _sample_issue_lock(**overrides) return [], [], {"status": "not_claimed"}
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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -181,70 +185,63 @@ class TestCreateIssue(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestCreatePR(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", @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, [])) return_value=(True, []))
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_pr(self, _auth, mock_api, _role): @patch("os.path.exists", return_value=True)
worktree = os.path.realpath(os.getcwd()) @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"} mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
with tempfile.TemporaryDirectory() as lock_dir: with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
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,
)
self.assertEqual(result["number"], 3) self.assertEqual(result["number"], 3)
self.assertNotIn("url", result) 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] payload = mock_api.call_args[0][3]
self.assertEqual(payload["head"], "feat/x") self.assertEqual(payload["head"], "feat/x")
self.assertEqual(payload["base"], "main") self.assertEqual(payload["base"], "main")
self.assertIn("Closes #123", payload["title"]) 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", @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, [])) return_value=(True, []))
@patch("mcp_server.api_request") @patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role): @patch("os.path.exists", return_value=True)
worktree = os.path.realpath(os.getcwd()) @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"} mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
with tempfile.TemporaryDirectory() as lock_dir: env = {**CREATE_PR_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"} with patch.dict(os.environ, env, clear=True):
with patch.dict(os.environ, env, clear=True): result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
_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,
)
self.assertIn("pulls/3", result["url"]) self.assertIn("pulls/3", result["url"])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, [])) return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_locked_issue_mismatch_fails(self, _auth, _role): @patch("os.path.exists", return_value=True)
worktree = os.path.realpath(os.getcwd()) @patch("builtins.open")
with tempfile.TemporaryDirectory() as lock_dir: def test_create_pr_locked_issue_mismatch_fails(self, mock_open, mock_exists, _auth, _role):
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x"))
with patch.dict(os.environ, env, clear=True): mock_open.return_value.__enter__.return_value.read.return_value = lock_json
_bind_test_lock( with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
issue_number=123, with self.assertRaises(ValueError) as ctx:
branch_name="feat/x", gitea_create_pr(title="feat: X Closes #999", head="feat/x", base="main")
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,
)
self.assertIn("Closes #123", str(ctx.exception)) 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.""" """Test issue locking and PR gating constraints."""
def setUp(self): def setUp(self):
self._lock_dir = tempfile.TemporaryDirectory() self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
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.start() 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): def tearDown(self):
self._dup_fetcher_patcher.stop()
self._env_patcher.stop() self._env_patcher.stop()
self._lock_dir.cleanup() if os.path.exists(ISSUE_LOCK_FILE):
os.remove(ISSUE_LOCK_FILE)
def _create_pr_env(self) -> dict:
return {
**CREATE_PR_ENV,
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
}
@patch( @patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state", "mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(), return_value=_clean_master_git_state_for_lock(),
) )
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_success(self, _auth, mock_api, _git_state): def test_lock_issue_success(self, _auth, _git_state):
mock_api.return_value = [] # no open PRs
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertTrue(res["success"]) self.assertTrue(res["success"])
self.assertEqual(res["work_lease"]["operation_type"], "author_issue_work") 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("expires_at", res["work_lease"])
self.assertIn("last_heartbeat_at", res["work_lease"]) self.assertIn("last_heartbeat_at", res["work_lease"])
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default") self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
self.assertIn("lock_file_path", res) self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
lock = issue_lock_store.read_lock_file(res["lock_file_path"]) with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
lock = json.load(f)
self.assertIn("worktree_path", lock) self.assertIn("worktree_path", lock)
self.assertIn("work_lease", lock) self.assertIn("work_lease", lock)
@@ -3121,128 +3113,75 @@ class TestIssueLocking(unittest.TestCase):
"mcp_server.issue_lock_worktree.read_worktree_git_state", "mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(), return_value=_clean_master_git_state_for_lock(),
) )
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @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): def test_lock_issue_reused_by_open_pr_branch(self, _auth, _git_state):
mock_api.return_value = [{ self.mock_dup_fetcher.return_value = ([{
"number": 200, "number": 200,
"head": {"ref": "feat/issue-196-boundary"}, "head": {"ref": "feat/issue-196-boundary"},
"title": "Some PR", "title": "Some PR",
"body": "No closes ref" "body": "No closes ref",
}] }], [], {"status": "not_claimed"})
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") 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( @patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state", "mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(), return_value=_clean_master_git_state_for_lock(),
) )
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @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): def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, _git_state):
mock_api.return_value = [{ self.mock_dup_fetcher.return_value = ([{
"number": 200, "number": 200,
"head": {"ref": "feat/other-branch"}, "head": {"ref": "feat/other-branch"},
"title": "Some PR", "title": "Some PR",
"body": "fixes #196" "body": "fixes #196",
}] }], [], {"status": "not_claimed"})
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") 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( @patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state", "mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(), return_value=_clean_master_git_state_for_lock(),
) )
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_reused_by_remote_branch(self, _auth, mock_api, _git_state): def test_lock_issue_reused_by_remote_branch(self, _auth, _git_state):
mock_api.side_effect = [ 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: with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") 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( def test_lock_issue_blocks_active_same_operation_lease(self):
"mcp_server.issue_lock_worktree.read_worktree_git_state", with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
return_value=_clean_master_git_state_for_lock(), json.dump({
)
@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,
),
{
"issue_number": 196, "issue_number": 196,
"branch_name": "feat/issue-196-other-work", "branch_name": "feat/issue-196-other-work",
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": prgs_repo,
"worktree_path": "/tmp/other-worktree", "worktree_path": "/tmp/other-worktree",
"work_lease": { "work_lease": {
"operation_type": "author_issue_work", "operation_type": "author_issue_work",
"expires_at": "2999-01-01T00:00:00Z", "expires_at": "2999-01-01T00:00:00Z",
}, },
}, }, f)
)
with self.assertRaises(RuntimeError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") 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)) self.assertIn("already has an active author_issue_work lease", str(ctx.exception))
@patch( def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self):
"mcp_server.issue_lock_worktree.read_worktree_git_state", with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
return_value=_clean_master_git_state_for_lock(), json.dump({
)
@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,
),
{
"issue_number": 196, "issue_number": 196,
"branch_name": "feat/issue-196-other-work", "branch_name": "feat/issue-196-other-work",
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": prgs_repo,
"worktree_path": "/tmp/other-worktree", "worktree_path": "/tmp/other-worktree",
"work_lease": { "work_lease": {
"operation_type": "author_issue_work", "operation_type": "author_issue_work",
"expires_at": "2000-01-01T00:00:00Z", "expires_at": "2000-01-01T00:00:00Z",
}, },
}, }, f)
)
with self.assertRaises(RuntimeError) as ctx: with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") 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)) self.assertIn("Recovery review is required before takeover", str(ctx.exception))
@@ -3309,7 +3248,9 @@ class TestIssueLocking(unittest.TestCase):
return_value=(True, [])) return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_lock_fails(self, _auth, _role): 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: with self.assertRaises(RuntimeError) as ctx:
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-mutations", remote="prgs") gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("Issue lock is missing", str(ctx.exception)) self.assertIn("Issue lock is missing", str(ctx.exception))
@@ -3318,64 +3259,37 @@ class TestIssueLocking(unittest.TestCase):
return_value=(True, [])) return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_branch_mismatch_fails(self, _auth, _role): def test_create_pr_branch_mismatch_fails(self, _auth, _role):
worktree = os.path.realpath(os.getcwd()) with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
_bind_test_lock( json.dump(_sample_issue_lock(
issue_number=196, issue_number=196, branch_name="feat/issue-196-mutations"), f)
branch_name="feat/issue-196-mutations", with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
remote="prgs",
worktree_path=worktree,
)
with patch.dict(os.environ, self._create_pr_env(), clear=True):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
gitea_create_pr( gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-different", remote="prgs")
title="feat: X Closes #196",
head="feat/issue-196-different",
remote="prgs",
worktree_path=worktree,
)
self.assertIn("does not match locked branch", str(ctx.exception)) self.assertIn("does not match locked branch", str(ctx.exception))
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, [])) return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_forbidden_terms_fails(self, _auth, _role): def test_create_pr_forbidden_terms_fails(self, _auth, _role):
worktree = os.path.realpath(os.getcwd()) with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
_bind_test_lock( json.dump(_sample_issue_lock(
issue_number=196, issue_number=196, branch_name="feat/issue-196-mutations"), f)
branch_name="feat/issue-196-mutations", with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
remote="prgs",
worktree_path=worktree,
)
with patch.dict(os.environ, self._create_pr_env(), clear=True):
for term in ("equivalent to #196", "related to #196", "same as #196"): for term in ("equivalent to #196", "related to #196", "same as #196"):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
gitea_create_pr( gitea_create_pr(title=f"feat: X {term}", head="feat/issue-196-mutations", remote="prgs")
title=f"feat: X {term}",
head="feat/issue-196-mutations",
remote="prgs",
worktree_path=worktree,
)
self.assertIn("contains forbidden term", str(ctx.exception)) self.assertIn("contains forbidden term", str(ctx.exception))
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, [])) return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_closes_ref_fails(self, _auth, _role): def test_create_pr_missing_closes_ref_fails(self, _auth, _role):
worktree = os.path.realpath(os.getcwd()) with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
_bind_test_lock( json.dump(_sample_issue_lock(
issue_number=196, issue_number=196, branch_name="feat/issue-196-mutations"), f)
branch_name="feat/issue-196-mutations", with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
remote="prgs",
worktree_path=worktree,
)
with patch.dict(os.environ, self._create_pr_env(), clear=True):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
gitea_create_pr( gitea_create_pr(title="feat: X refs #196", head="feat/issue-196-mutations", remote="prgs")
title="feat: X refs #196",
head="feat/issue-196-mutations",
remote="prgs",
worktree_path=worktree,
)
self.assertIn("must contain 'Closes #196' or 'Fixes #196' exactly", str(ctx.exception)) 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", @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) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_worktree_mismatch_fails(self, _auth, _role): def test_create_pr_worktree_mismatch_fails(self, _auth, _role):
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-pr") scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-pr")
_bind_test_lock( with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
issue_number=249, json.dump(_sample_issue_lock(
branch_name="feat/issue-249-issue-lock-scratch-worktree", issue_number=249,
worktree_path=scratch, branch_name="feat/issue-249-issue-lock-scratch-worktree",
remote="prgs", worktree_path=scratch,
) ), f)
with patch.dict(os.environ, self._create_pr_env(), clear=True): with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
with self.assertRaises(ValueError) as ctx: with self.assertRaises(ValueError) as ctx:
gitea_create_pr( gitea_create_pr(
title="feat: lock scratch worktree Closes #249", 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)) 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.api_request")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, [])) return_value=(True, []))
@@ -3406,13 +3342,13 @@ class TestIssueLocking(unittest.TestCase):
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api): 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") scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e")
mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"} mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"}
_bind_test_lock( with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
issue_number=249, json.dump(_sample_issue_lock(
branch_name="feat/issue-249-issue-lock-scratch-worktree", issue_number=249,
worktree_path=scratch, branch_name="feat/issue-249-issue-lock-scratch-worktree",
remote="prgs", worktree_path=scratch,
) ), f)
with patch.dict(os.environ, self._create_pr_env(), clear=True): with patch.dict(os.environ, CREATE_PR_ENV, clear=True):
res = gitea_create_pr( res = gitea_create_pr(
title="feat: issue-lock scratch worktree Closes #249", title="feat: issue-lock scratch worktree Closes #249",
head="feat/issue-249-issue-lock-scratch-worktree", head="feat/issue-249-issue-lock-scratch-worktree",
+1
View File
@@ -2346,6 +2346,7 @@ class TestWorkIssueFinalReport(unittest.TestCase):
"- Safe next action: open PR", "- Safe next action: open PR",
"- Next: open PR", "- Next: open PR",
"- Safety statement: no review/merge", "- Safety statement: no review/merge",
"- Duplicate work outcome: duplicate work not prevented",
]) ])
def test_complete_work_issue_report_earns_a(self): def test_complete_work_issue_report_earns_a(self):
+9 -26
View File
@@ -20,50 +20,33 @@ def run(script, *args):
branch = arg branch = arg
break break
lock_dir_ctx = None lock_file = Path("/tmp/gitea_issue_lock.json")
extra_env = os.environ.copy() created_lock = False
if script == "worktree-start" and branch: if script == "worktree-start" and branch:
import re import re
import tempfile import json
import issue_lock_store
m = re.search(r"issue-(\d+)", branch) m = re.search(r"issue-(\d+)", branch)
if not m: if not m:
m = re.search(r"pr-(\d+)", branch) m = re.search(r"pr-(\d+)", branch)
issue_num = int(m.group(1)) if m else 999 issue_num = int(m.group(1)) if m else 999
lock_dir_ctx = tempfile.TemporaryDirectory() lock_file.write_text(json.dumps({
extra_env["GITEA_ISSUE_LOCK_DIR"] = lock_dir_ctx.name
record = {
"issue_number": issue_num, "issue_number": issue_num,
"branch_name": branch, "branch_name": branch,
"remote": "prgs", "remote": "prgs",
"org": "Scaled-Tech-Consulting", "org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools", "repo": "Gitea-Tools"
"worktree_path": "/tmp/test-worktree", }), encoding="utf-8")
"work_lease": { created_lock = True
"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)
try: try:
proc = subprocess.run( proc = subprocess.run(
["bash", str(SCRIPTS / script), *args], ["bash", str(SCRIPTS / script), *args],
capture_output=True, text=True, cwd=str(REPO), capture_output=True, text=True, cwd=str(REPO),
env=extra_env,
) )
return proc.returncode, proc.stdout, proc.stderr return proc.returncode, proc.stdout, proc.stderr
finally: finally:
if lock_dir_ctx is not None: if created_lock and lock_file.exists():
lock_dir_ctx.cleanup() lock_file.unlink()
class TestWorktreeStart(unittest.TestCase): class TestWorktreeStart(unittest.TestCase):