Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a0603fc3 |
@@ -46,12 +46,3 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
||||
# profile's values. Leave unset for pure env-based configuration.
|
||||
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
||||
GITEA_MCP_PROFILE=prgs
|
||||
|
||||
# Namespace-scoped active task workspaces (#510). Each MCP namespace uses only
|
||||
# its own role env var; foreign bindings (e.g. GITEA_AUTHOR_WORKTREE in a
|
||||
# merger process) are ignored.
|
||||
# GITEA_AUTHOR_WORKTREE=/path/to/repo/branches/issue-123-work
|
||||
# GITEA_REVIEWER_WORKTREE=/path/to/repo/branches/review-pr456
|
||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
||||
|
||||
@@ -7,13 +7,8 @@ project's ``branches/`` directory, never from the stable control checkout.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||
# via namespace_workspace_binding (#510).
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
@@ -53,130 +48,6 @@ def resolve_mutation_workspace(
|
||||
return os.path.realpath(project_root)
|
||||
|
||||
|
||||
def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
|
||||
"""Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
|
||||
raw = (common_dir or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
if os.path.isabs(raw):
|
||||
return os.path.realpath(raw)
|
||||
return os.path.realpath(os.path.join(workspace_path, raw))
|
||||
|
||||
|
||||
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
||||
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
||||
path = (workspace_path or "").strip()
|
||||
fallback = os.path.realpath(fallback_project_root)
|
||||
if not path:
|
||||
return fallback
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common = _realpath_git_common_dir(path, res.stdout)
|
||||
except Exception:
|
||||
return fallback
|
||||
if common.endswith(f"{os.sep}.git"):
|
||||
return os.path.dirname(common)
|
||||
if os.path.basename(common) == ".git":
|
||||
return os.path.dirname(common)
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_author_mutation_context(
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
*,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards (#460)."""
|
||||
workspace = resolve_mutation_workspace(
|
||||
worktree_path,
|
||||
process_project_root,
|
||||
active_worktree_env=active_worktree_env,
|
||||
author_worktree_env=author_worktree_env,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
# Canonical repository identity comes from the MCP process checkout (#460),
|
||||
# not from the declared task workspace being validated.
|
||||
canonical_root = resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_workspace_repo_membership(
|
||||
*,
|
||||
workspace_path: str,
|
||||
canonical_repo_root: str,
|
||||
) -> dict:
|
||||
"""Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not os.path.exists(workspace):
|
||||
reasons.append(f"worktree path '{workspace}' does not exist")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
if not os.path.isdir(workspace):
|
||||
reasons.append(f"worktree path '{workspace}' is not a directory")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", workspace, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common_dir = _realpath_git_common_dir(workspace, res.stdout)
|
||||
except Exception:
|
||||
reasons.append(f"worktree '{workspace}' is not a valid git repository")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
expected_dir = os.path.realpath(os.path.join(root, ".git"))
|
||||
if common_dir != expected_dir:
|
||||
reasons.append(
|
||||
f"worktree '{workspace}' does not belong to the target repository '{root}'"
|
||||
)
|
||||
return _membership_assessment(not reasons, reasons, workspace, root, common_dir)
|
||||
|
||||
|
||||
def _membership_assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
workspace: str,
|
||||
root: str,
|
||||
common_dir: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"workspace_path": workspace,
|
||||
"canonical_repo_root": root,
|
||||
"git_common_dir": common_dir,
|
||||
}
|
||||
|
||||
|
||||
def format_workspace_repo_membership_error(assessment: dict) -> str:
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
|
||||
return (
|
||||
f"Branches-only mutation guard (#274): {reasons} (fail closed). "
|
||||
f"canonical repository root: {root}; workspace: {workspace}."
|
||||
)
|
||||
|
||||
|
||||
def assess_author_mutation_worktree(
|
||||
*,
|
||||
workspace_path: str,
|
||||
|
||||
@@ -274,72 +274,12 @@ is proven abandoned and the takeover is recorded.
|
||||
|
||||
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
|
||||
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
|
||||
records an `author_issue_work` lease in a keyed lock file under
|
||||
`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file
|
||||
per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds
|
||||
its active lock through a per-process pointer so concurrent repos/issues never
|
||||
share one overwrite-prone slot (#443).
|
||||
|
||||
Each lock payload includes issue number, optional PR number, branch, worktree
|
||||
path, claimant identity/profile, created timestamp, expiry timestamp, and last
|
||||
heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
|
||||
work. An expired lease still blocks takeover until a recovery review records why
|
||||
the prior work is abandoned, completed, or unsafe to continue.
|
||||
|
||||
**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to
|
||||
`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately
|
||||
based on another unmerged PR's branch) is an explicit, opt-in path: pass
|
||||
`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock
|
||||
fails closed unless that branch is owned by a live **open** PR whose number
|
||||
matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as
|
||||
bases. When approved, the lock payload records
|
||||
`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may
|
||||
be base-equivalent to that branch instead of master. `gitea_create_pr` then
|
||||
allows `base = <that branch>` only when it matches the recorded approval, the
|
||||
dependency PR is **still open**, and the PR body documents the stack:
|
||||
|
||||
- `Stacked on PR #<X> / issue #<Y>`
|
||||
- `Base branch: <feature-branch>`
|
||||
- `Head branch: <this-issue-branch>`
|
||||
- `Do not merge before PR #<X>` (merge ordering)
|
||||
- retarget/rebase to `master` after the dependency lands, if required
|
||||
|
||||
Stacked support never bypasses the issue lock — the base is recorded *on* the
|
||||
lock and re-verified at PR time. A merged/closed dependency base fails closed;
|
||||
retarget onto `master` or re-lock against a live base.
|
||||
|
||||
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
|
||||
recovery path.** That global slot is deprecated and can clobber unrelated live
|
||||
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
|
||||
adoption rebinds the session when the issue's exact branch already exists (#442).
|
||||
`gitea_create_pr` resolves the durable keyed lock by session pointer or by
|
||||
matching `head` branch without unsafe manual seeding.
|
||||
|
||||
**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
|
||||
`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
|
||||
shared state and manual writes can clobber another session's live lease. Use
|
||||
`sanctioned recovery` instead:
|
||||
|
||||
1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
|
||||
2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
|
||||
3. Operator override only when explicitly authorized — record
|
||||
`External-state mutations` and `operator override proof` in the final report.
|
||||
|
||||
**Adoption proof in the live lock response (#477):** when `gitea_lock_issue`
|
||||
adopts an existing own branch, the response carries an `adoption` block with
|
||||
citable fields — `adoption_decision` (`ADOPT`), `adopted` (`true`),
|
||||
`adopted_branch`, `adopted_branch_head`, `matcher_summary` (boundary-safe reason
|
||||
the branch qualified), `competing_branch_check`, and `safe_next_action`. A normal
|
||||
lock instead returns an `adoption_check` block with `adoption_decision`
|
||||
(`NO_MATCH`) and `adopted: false`, so a non-adoption response can never be misread
|
||||
as claiming adoption. Recovery reports should quote the live lock response
|
||||
`adoption`/`adoption_check` block directly instead of inferring adoption from
|
||||
separate offline checks.
|
||||
|
||||
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
|
||||
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
|
||||
under `External-state mutations: none` or mix author PR creation with reviewer
|
||||
approval in one run. See also #438 (global lock redesign).
|
||||
records an `author_issue_work` lease in the issue-lock payload with issue
|
||||
number, optional PR number, branch, worktree path, claimant identity/profile,
|
||||
created timestamp, expiry timestamp, and last heartbeat timestamp. An active
|
||||
same-issue/same-operation lease blocks duplicate work. An expired lease still
|
||||
blocks takeover until a recovery review records why the prior work is abandoned,
|
||||
completed, or unsafe to continue.
|
||||
|
||||
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
|
||||
@@ -823,45 +763,6 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
```
|
||||
|
||||
## Namespace workspace binding (#510)
|
||||
|
||||
Each MCP namespace resolves its **own** active task workspace. Foreign role
|
||||
worktree environment variables must not poison another namespace's purity
|
||||
checks.
|
||||
|
||||
| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
|
||||
|-----------|------------------------------------------------------------------|---------------|
|
||||
| author | `GITEA_AUTHOR_WORKTREE` | `branches/<task>` worktree only (#274) |
|
||||
| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/<review>` worktree |
|
||||
| merger | `GITEA_MERGER_WORKTREE` | clean `branches/<merge>` worktree **or** clean control checkout |
|
||||
| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/<reconcile>` worktree **or** clean control checkout |
|
||||
|
||||
`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler
|
||||
MCP processes ignore it even when it points at a dirty author WIP tree.
|
||||
|
||||
### Safe reconnect / rebind procedure
|
||||
|
||||
When a mutation blocks on workspace binding:
|
||||
|
||||
1. Read the error — it names the **resolved workspace path**, **role
|
||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
||||
workspace (or set the role-specific env var before launch).
|
||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
||||
branches/ worktree differs from the MCP process root.
|
||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||
own namespace — that destroys another agent's WIP.
|
||||
|
||||
### CTH guidance for workspace binding blockers
|
||||
|
||||
When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||
- Quote the resolved workspace path and binding source from the error.
|
||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||
|
||||
@@ -11,8 +11,6 @@ import inspect
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
import issue_lock_provenance
|
||||
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
|
||||
from review_proofs import (
|
||||
HANDOFF_HEADING,
|
||||
assess_controller_handoff,
|
||||
@@ -22,7 +20,6 @@ from review_proofs import (
|
||||
assess_review_mutation_final_report,
|
||||
assess_validation_report,
|
||||
)
|
||||
from validation_status_vocabulary import assess_validation_status_vocabulary
|
||||
|
||||
FINAL_REPORT_TASK_KINDS = frozenset({
|
||||
"review_pr",
|
||||
@@ -493,81 +490,6 @@ def _rule_reviewer_validation_failure_history(
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_validation_cwd_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report
|
||||
|
||||
session = validation_session or {}
|
||||
claims = (
|
||||
session.get("validation_ran")
|
||||
or session.get("command")
|
||||
or session.get("baseline_validation_ran")
|
||||
)
|
||||
if not claims and "validation command:" not in (report_text or "").lower():
|
||||
return []
|
||||
|
||||
result = assess_validation_cwd_proof_report(
|
||||
report_text,
|
||||
validation_session=session,
|
||||
)
|
||||
if result.get("proven") or not result.get("claims_validation"):
|
||||
return []
|
||||
severity = "block" if result.get("violations") else "downgrade"
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.validation_cwd_proof",
|
||||
severity,
|
||||
"Validation cwd/HEAD proof",
|
||||
reason,
|
||||
result.get("safe_next_action")
|
||||
or "document pwd, HEAD SHA, and explicit cwd before validation",
|
||||
)
|
||||
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from pr_work_lease import assess_reviewer_stale_head_final_report
|
||||
|
||||
result = assess_reviewer_stale_head_final_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.stale_head_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Stale-head proof",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"state reviewed head SHA, live head before approval/merge, and "
|
||||
"whether any push occurred during validation"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
|
||||
from pr_work_lease import assess_conflict_fix_final_report
|
||||
|
||||
text = report_text or ""
|
||||
if "conflict-fix" not in text.lower() and "conflict fix" not in text.lower():
|
||||
return []
|
||||
result = assess_conflict_fix_final_report(text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"author.conflict_fix_push_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Conflict-fix push proof",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"state branch head before/after push, reviewer lease status, "
|
||||
"fast-forward status, and whether any reviewer was active"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _BARE_PYTEST_RE.search(text):
|
||||
@@ -676,34 +598,6 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st
|
||||
]
|
||||
|
||||
|
||||
def _rule_reviewer_validation_status_vocabulary(
|
||||
report_text: str,
|
||||
*,
|
||||
action_log: list[dict] | None = None,
|
||||
) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not re.search(
|
||||
r"validation status|pr-head validation status|official validation status",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return []
|
||||
result = assess_validation_status_vocabulary(
|
||||
text,
|
||||
command_log=action_log,
|
||||
)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.validation_status_vocabulary",
|
||||
result.get("reasons") or [],
|
||||
field="Validation status",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "use a validation status that matches the proof path executed",
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if "baseline worktree path" not in text.lower():
|
||||
@@ -976,54 +870,6 @@ def _rule_reviewer_mutation_ledger(
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_issue_lock_external_state(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_issue_lock_external_state_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.issue_lock_external_state",
|
||||
result.get("reasons") or [],
|
||||
field="External-state mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"disclose gitea_issue_lock.json read/write/delete under "
|
||||
"External-state mutations; never claim none after lock seeding"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_manual_lock_pr_without_override(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.manual_lock_pr_override",
|
||||
result.get("reasons") or [],
|
||||
field="External-state mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"use gitea_lock_issue or #442 adoption instead of manual lock seeding; "
|
||||
"if operator override was authorized, cite override proof"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, str]]:
|
||||
result = issue_lock_provenance.assess_author_reviewer_same_run_report(report_text)
|
||||
if result.get("proven"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"shared.author_reviewer_same_run",
|
||||
result.get("reasons") or [],
|
||||
field="Review mutations",
|
||||
severity="block",
|
||||
safe_next_action=(
|
||||
"split author PR creation and reviewer approval across separate "
|
||||
"sessions and handoffs"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_review_mutation(
|
||||
report_text: str,
|
||||
*,
|
||||
@@ -1043,42 +889,19 @@ def _rule_reviewer_review_mutation(
|
||||
)
|
||||
|
||||
|
||||
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
|
||||
result = assess_post_merge_cleanup_proof(report_text)
|
||||
if not result.get("block"):
|
||||
return []
|
||||
return _findings_from_reasons(
|
||||
"reviewer.post_merge_cleanup_proof",
|
||||
result.get("reasons") or [],
|
||||
field="Cleanup status",
|
||||
severity="block",
|
||||
safe_next_action=result.get("safe_next_action")
|
||||
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
|
||||
)
|
||||
|
||||
|
||||
_SHARED_ISSUE_LOCK_RULES = (
|
||||
_rule_shared_issue_lock_external_state,
|
||||
_rule_shared_manual_lock_pr_override,
|
||||
_rule_shared_author_reviewer_same_run,
|
||||
)
|
||||
|
||||
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"review_pr": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_legacy_workspace_mutations,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_reviewer_mutation_categories,
|
||||
_rule_reviewer_git_fetch_readonly,
|
||||
_rule_reviewer_validation_command,
|
||||
_rule_reviewer_validation_failure_history,
|
||||
_rule_reviewer_validation_cwd_proof,
|
||||
_rule_reviewer_validation_structured,
|
||||
_rule_reviewer_linked_issue,
|
||||
_rule_reviewer_baseline_on_failure,
|
||||
_rule_reviewer_validation_status_vocabulary,
|
||||
_rule_reviewer_main_checkout_baseline,
|
||||
_rule_reviewer_main_checkout_path,
|
||||
_rule_reviewer_already_landed_eligible,
|
||||
@@ -1086,13 +909,10 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
_rule_reviewer_target_branch_freshness,
|
||||
_rule_reviewer_mutation_ledger,
|
||||
_rule_reviewer_review_mutation,
|
||||
_rule_reviewer_post_merge_cleanup_proof,
|
||||
_rule_reviewer_stale_head_proof,
|
||||
],
|
||||
"reconcile_already_landed": [
|
||||
_rule_reconcile_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_stale_author_fields,
|
||||
_rule_reconcile_eligible_reviewed,
|
||||
_rule_reconcile_linked_issue_live,
|
||||
@@ -1104,31 +924,25 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
||||
"author_issue": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
],
|
||||
"work_issue": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reviewer_vague_mutations_none,
|
||||
_rule_conflict_fix_push_proof,
|
||||
],
|
||||
"issue_filing": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
"inventory": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
_rule_reconcile_pagination_proof,
|
||||
],
|
||||
"issue_selection": [
|
||||
_rule_shared_controller_handoff,
|
||||
_rule_shared_email_disclosure,
|
||||
*_SHARED_ISSUE_LOCK_RULES,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
+169
-1255
File diff suppressed because it is too large
Load Diff
@@ -1,272 +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 re
|
||||
|
||||
ADOPT = "adopt_existing_branch"
|
||||
BLOCK_COMPETING = "block_competing_branch"
|
||||
NO_MATCH = "no_matching_branch"
|
||||
|
||||
# Citable decision labels aligned with the ``assess_own_branch_adoption``
|
||||
# outcomes, surfaced verbatim in the live ``gitea_lock_issue`` response so
|
||||
# recovery reports (#473-style) can quote the lock tool output directly
|
||||
# instead of inferring adoption from separate offline checks (#477).
|
||||
DECISION_LABELS = {
|
||||
ADOPT: "ADOPT",
|
||||
BLOCK_COMPETING: "BLOCK_COMPETING",
|
||||
NO_MATCH: "NO_MATCH",
|
||||
}
|
||||
|
||||
_SAFE_NEXT_ACTIONS = {
|
||||
ADOPT: (
|
||||
"Own existing branch adopted for lock recovery; proceed to "
|
||||
"gitea_create_pr for this issue and cite this adoption proof."
|
||||
),
|
||||
BLOCK_COMPETING: (
|
||||
"Competing same-issue branch(es) exist; resolve branch ownership "
|
||||
"before locking. No adoption performed (fail closed)."
|
||||
),
|
||||
NO_MATCH: (
|
||||
"No existing branch carries this issue marker; normal lock path "
|
||||
"applied. No adoption performed."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def decision_label(outcome: str) -> str:
|
||||
"""Map an ``assess_own_branch_adoption`` outcome to its citable label."""
|
||||
return DECISION_LABELS.get(outcome, "UNKNOWN")
|
||||
|
||||
|
||||
def safe_next_action(outcome: str) -> str:
|
||||
"""Return the safe next action string for an adoption *outcome*."""
|
||||
return _SAFE_NEXT_ACTIONS.get(
|
||||
outcome, "Unknown adoption outcome; treat as fail closed."
|
||||
)
|
||||
|
||||
|
||||
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 _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||
"""Return True when *branch_name* references issue *issue_number* exactly.
|
||||
|
||||
Uses a numeric word-boundary so ``issue-42`` does not match inside
|
||||
``issue-420`` (AC6 / #440).
|
||||
"""
|
||||
name = (branch_name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
pattern = rf"(?:^|/)issue-{int(issue_number)}(?![0-9])"
|
||||
return re.search(pattern, name) is not None
|
||||
|
||||
|
||||
def assess_own_branch_adoption(
|
||||
*,
|
||||
issue_number: int,
|
||||
requested_branch: str,
|
||||
existing_branches,
|
||||
) -> dict:
|
||||
"""Decide whether an existing matching branch is adoptable.
|
||||
|
||||
Args:
|
||||
issue_number: The tracking issue number being locked.
|
||||
requested_branch: The exact branch the caller wants to lock.
|
||||
existing_branches: Iterable of remote branch entries — either names or
|
||||
dicts with ``name`` and optional ``commit_sha``.
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
* ``outcome`` — one of ADOPT / BLOCK_COMPETING / NO_MATCH
|
||||
* ``adopt`` (bool), ``block`` (bool)
|
||||
* ``reason`` (str)
|
||||
* ``matched_branch`` (str | None), ``matched_head_sha`` (str | None)
|
||||
* ``competing_branches`` (list[str])
|
||||
|
||||
ADOPT: the issue's exact branch exists and no other same-issue branch does.
|
||||
BLOCK_COMPETING: at least one same-issue branch is not the requested branch.
|
||||
NO_MATCH: no branch carries the issue marker — normal lock path applies.
|
||||
"""
|
||||
requested = (requested_branch or "").strip()
|
||||
|
||||
matches: list[tuple[str, str | None]] = []
|
||||
for entry in existing_branches or []:
|
||||
name = _branch_name(entry).strip()
|
||||
if _branch_carries_issue_marker(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]
|
||||
|
||||
# Fail closed whenever any non-requested same-issue branch exists, even if
|
||||
# the requested branch is also present: ownership is then ambiguous.
|
||||
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 _matcher_summary(issue_number: int, assessment: dict) -> str:
|
||||
"""Explain, citably, why the assessed branch did or did not qualify.
|
||||
|
||||
Names the numeric word-boundary rule so reports can show that
|
||||
``issue-42`` was not matched inside ``issue-420`` (#440 / #477 AC3).
|
||||
"""
|
||||
outcome = assessment.get("outcome")
|
||||
matched = assessment.get("matched_branch")
|
||||
competing = assessment.get("competing_branches") or []
|
||||
if outcome == ADOPT and matched:
|
||||
return (
|
||||
f"branch '{matched}' exactly matches the issue-{int(issue_number)} "
|
||||
f"marker (numeric word-boundary; 'issue-{int(issue_number)}' is not "
|
||||
f"matched inside 'issue-{int(issue_number)}0')"
|
||||
)
|
||||
if outcome == BLOCK_COMPETING:
|
||||
return (
|
||||
f"competing same-issue branch(es) {competing} carry the "
|
||||
f"issue-{int(issue_number)} marker but are not the requested "
|
||||
f"branch; ownership is ambiguous (fail closed)"
|
||||
)
|
||||
return (
|
||||
f"no existing branch carries the issue-{int(issue_number)} marker "
|
||||
f"under the numeric word-boundary rule"
|
||||
)
|
||||
|
||||
|
||||
def _competing_branch_check(assessment: dict) -> dict:
|
||||
"""Structured competing-branch verdict for the proof block."""
|
||||
competing = list(assessment.get("competing_branches") or [])
|
||||
return {
|
||||
"result": "blocked" if competing else "clear",
|
||||
"competing_branches": competing,
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Requirement #4: adoption results must carry issue number, branch name,
|
||||
branch head commit, adoption reason, no-existing-PR proof, no-competing-
|
||||
live-lock proof, and lock file path/status.
|
||||
|
||||
#477: additionally surface explicit, citable adoption-proof fields tied to
|
||||
the ``assess_own_branch_adoption`` outcome (``adoption_decision``,
|
||||
``adopted``, ``adopted_branch``, ``adopted_branch_head``,
|
||||
``matcher_summary``, ``competing_branch_check``, ``safe_next_action``) so a
|
||||
recovery session can quote the live lock response directly. The explicit
|
||||
fields are populated for any outcome; ``adopted_branch`` /
|
||||
``adopted_branch_head`` are set only when the outcome is ADOPT so a
|
||||
non-adoption proof can never be misread as claiming adoption.
|
||||
"""
|
||||
outcome = assessment.get("outcome")
|
||||
adopted = outcome == ADOPT
|
||||
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,
|
||||
# Explicit citable fields (#477).
|
||||
"adoption_decision": decision_label(outcome),
|
||||
"adopted": adopted,
|
||||
"adopted_branch": branch_name if adopted else None,
|
||||
"adopted_branch_head": assessment.get("matched_head_sha") if adopted else None,
|
||||
"matcher_summary": _matcher_summary(issue_number, assessment),
|
||||
"competing_branch_check": _competing_branch_check(assessment),
|
||||
"safe_next_action": safe_next_action(outcome),
|
||||
}
|
||||
|
||||
|
||||
def build_non_adoption_lock_proof(*, issue_number: int, branch_name: str) -> dict:
|
||||
"""Safe, adoption-free proof metadata for a normal (NO_MATCH) lock.
|
||||
|
||||
Requirement #477 AC2: non-adoption lock responses must stay clear and must
|
||||
not imply adoption. This returns explicit ``adopted: False`` metadata with
|
||||
the ``NO_MATCH`` decision so a normal lock response can carry citable proof
|
||||
without ever asserting a branch was adopted.
|
||||
"""
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"adoption_decision": DECISION_LABELS[NO_MATCH],
|
||||
"adopted": False,
|
||||
"adopted_branch": None,
|
||||
"adopted_branch_head": None,
|
||||
"matcher_summary": (
|
||||
f"no existing branch carries the issue-{int(issue_number)} marker; "
|
||||
f"normal lock path (no adoption)"
|
||||
),
|
||||
"competing_branch_check": {"result": "clear", "competing_branches": []},
|
||||
"safe_next_action": safe_next_action(NO_MATCH),
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
"""Issue-lock provenance and external-state disclosure (#447).
|
||||
|
||||
Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery
|
||||
#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be
|
||||
blocked at PR creation unless explicit operator override proof is recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
|
||||
|
||||
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
|
||||
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
|
||||
SOURCE_OPERATOR_OVERRIDE = "operator_override"
|
||||
|
||||
SANCTIONED_LOCK_SOURCES = frozenset({
|
||||
SOURCE_LOCK_ISSUE,
|
||||
SOURCE_LOCK_ADOPTION,
|
||||
SOURCE_OPERATOR_OVERRIDE,
|
||||
})
|
||||
|
||||
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
|
||||
|
||||
_ISSUE_LOCK_PATH_RE = re.compile(
|
||||
r"(?:/tmp/)?gitea_issue_lock\.json",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCK_SEED_RE = re.compile(
|
||||
r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|"
|
||||
r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_REMOVE_RE = re.compile(
|
||||
r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_READ_RE = re.compile(
|
||||
r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_EXTERNAL_NONE_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_FIELD_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_ONLY_RE = re.compile(
|
||||
r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_CREATED_RE = re.compile(
|
||||
r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|"
|
||||
r"PR\s+creation\s+(?:succeeded|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_APPROVE_RE = re.compile(
|
||||
r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|"
|
||||
r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OVERRIDE_PROOF_RE = re.compile(
|
||||
r"operator[- ]override\s+proof\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def build_sanctioned_lock_provenance(
|
||||
*,
|
||||
tool: str,
|
||||
source: str = SOURCE_LOCK_ISSUE,
|
||||
claimant: dict | None = None,
|
||||
adoption: dict | None = None,
|
||||
) -> dict:
|
||||
"""Return provenance metadata stored with a sanctioned lock write."""
|
||||
entry = {
|
||||
"source": source,
|
||||
"written_at": _utc_now_iso(),
|
||||
"written_by_tool": tool,
|
||||
"lock_file_path": ISSUE_LOCK_FILE,
|
||||
}
|
||||
if claimant:
|
||||
entry["claimant"] = claimant
|
||||
if adoption:
|
||||
entry["adoption"] = adoption
|
||||
return entry
|
||||
|
||||
|
||||
def operator_override_requested() -> bool:
|
||||
return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict:
|
||||
text = (reason or "").strip()
|
||||
if not text:
|
||||
raise ValueError(
|
||||
"operator override requires a non-empty override reason (fail closed)"
|
||||
)
|
||||
entry = build_sanctioned_lock_provenance(
|
||||
tool="operator_override",
|
||||
source=SOURCE_OPERATOR_OVERRIDE,
|
||||
claimant=claimant,
|
||||
)
|
||||
entry["override_reason"] = text
|
||||
return entry
|
||||
|
||||
|
||||
def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict:
|
||||
"""Fail closed when lock file lacks sanctioned provenance (#447)."""
|
||||
data = lock_data if isinstance(lock_data, dict) else {}
|
||||
reasons: list[str] = []
|
||||
provenance = data.get("lock_provenance")
|
||||
if not isinstance(provenance, dict):
|
||||
reasons.append(
|
||||
"issue lock file lacks sanctioned lock_provenance; manual seeding is "
|
||||
"not a normal recovery path — call gitea_lock_issue or use #442 adoption"
|
||||
)
|
||||
return _provenance_result(False, reasons, provenance)
|
||||
|
||||
source = str(provenance.get("source") or "").strip()
|
||||
if source not in SANCTIONED_LOCK_SOURCES:
|
||||
reasons.append(
|
||||
f"issue lock provenance source '{source or '(missing)'}' is not sanctioned"
|
||||
)
|
||||
|
||||
if source == SOURCE_OPERATOR_OVERRIDE and not str(
|
||||
provenance.get("override_reason") or ""
|
||||
).strip():
|
||||
reasons.append(
|
||||
"operator_override lock provenance requires override_reason proof"
|
||||
)
|
||||
|
||||
if not data.get("work_lease"):
|
||||
reasons.append("issue lock file missing work_lease metadata")
|
||||
|
||||
if not str(provenance.get("written_by_tool") or "").strip():
|
||||
reasons.append("issue lock provenance missing written_by_tool")
|
||||
|
||||
proven = not reasons
|
||||
return _provenance_result(proven, reasons, provenance)
|
||||
|
||||
|
||||
def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"lock_provenance": provenance,
|
||||
}
|
||||
|
||||
|
||||
def format_lock_provenance_error(assessment: dict) -> str:
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"])
|
||||
return f"Issue lock provenance guard (#447): {reasons} (fail closed)"
|
||||
|
||||
|
||||
def _lock_activity_detected(text: str) -> dict[str, bool]:
|
||||
body = text or ""
|
||||
return {
|
||||
"seed_or_restore": bool(_LOCK_SEED_RE.search(body)),
|
||||
"remove": bool(_LOCK_REMOVE_RE.search(body)),
|
||||
"read": bool(_LOCK_READ_RE.search(body)),
|
||||
}
|
||||
|
||||
|
||||
def _external_state_discloses_lock(text: str) -> bool:
|
||||
match = _EXTERNAL_FIELD_RE.search(text or "")
|
||||
if not match:
|
||||
return False
|
||||
value = (match.group(1) or "").strip().lower()
|
||||
if value in {"", "none", "n/a"}:
|
||||
return False
|
||||
return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value
|
||||
|
||||
|
||||
def assess_issue_lock_external_state_report(report_text: str) -> dict:
|
||||
"""Require explicit external-state disclosure for issue-lock mutations (#447)."""
|
||||
text = report_text or ""
|
||||
activity = _lock_activity_detected(text)
|
||||
if not any(activity.values()):
|
||||
return {"proven": True, "block": False, "reasons": [], "activity": activity}
|
||||
|
||||
reasons: list[str] = []
|
||||
disclosed = _external_state_discloses_lock(text)
|
||||
|
||||
if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions seeding/restoring gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif activity["seed_or_restore"] and not disclosed:
|
||||
reasons.append(
|
||||
"report mentions issue-lock file activity but External-state mutations "
|
||||
"does not disclose read/write of gitea_issue_lock.json"
|
||||
)
|
||||
|
||||
if activity["remove"]:
|
||||
if _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions removing gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif not disclosed and _CLEANUP_ONLY_RE.search(text):
|
||||
reasons.append(
|
||||
"report removes issue lock but classifies it as cleanup only; "
|
||||
"record under External-state mutations"
|
||||
)
|
||||
elif not disclosed:
|
||||
reasons.append(
|
||||
"report mentions deleting issue lock without External-state "
|
||||
"mutation disclosure"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"activity": activity,
|
||||
}
|
||||
|
||||
|
||||
def assess_manual_lock_pr_without_override(report_text: str) -> dict:
|
||||
"""Block reports that created a PR via manual lock seed without override proof."""
|
||||
text = report_text or ""
|
||||
seeded = bool(_LOCK_SEED_RE.search(text))
|
||||
created = bool(_PR_CREATED_RE.search(text))
|
||||
if not (seeded and created):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
if _OVERRIDE_PROOF_RE.search(text):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report created/opened a PR after manual issue-lock seeding without "
|
||||
"operator override proof"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def assess_author_reviewer_same_run_report(report_text: str) -> dict:
|
||||
"""Reviewer handoff must not create and approve the same PR in one run (#447)."""
|
||||
text = report_text or ""
|
||||
if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report mixes author-side PR creation and reviewer approval in one "
|
||||
"final handoff; split author and reviewer sessions"
|
||||
],
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438).
|
||||
|
||||
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. Acquisition is serialized per issue with ``fcntl.flock``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
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._+-]+")
|
||||
|
||||
|
||||
class LockContentionError(RuntimeError):
|
||||
"""Raised when an exclusive per-issue lock cannot be acquired."""
|
||||
|
||||
|
||||
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 flock_path(json_path: str) -> str:
|
||||
return f"{json_path}.lock"
|
||||
|
||||
|
||||
def is_process_alive(pid: int | None) -> bool:
|
||||
if not pid or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
except OSError as exc:
|
||||
return exc.errno != errno.ESRCH
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_path: str):
|
||||
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise LockContentionError(
|
||||
f"could not acquire exclusive lock on '{lock_path}'"
|
||||
) from exc
|
||||
yield fd
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
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()
|
||||
record.setdefault("pid", os.getpid())
|
||||
|
||||
pointer = {
|
||||
"pid": os.getpid(),
|
||||
"lock_file_path": path,
|
||||
"issue_number": issue_number,
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
sentinel = flock_path(path)
|
||||
try:
|
||||
with _exclusive_file_lock(sentinel):
|
||||
existing = read_lock_file(path)
|
||||
overwrite_block = assess_foreign_lock_overwrite(existing, record)
|
||||
if overwrite_block:
|
||||
raise RuntimeError(overwrite_block)
|
||||
lease_block = assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=issue_number,
|
||||
branch_name=str(record.get("branch_name") or ""),
|
||||
worktree_path=str(record.get("worktree_path") or ""),
|
||||
)
|
||||
if lease_block:
|
||||
raise RuntimeError(lease_block)
|
||||
save_lock_file(path, record)
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
except LockContentionError as exc:
|
||||
competing = read_lock_file(path)
|
||||
if competing:
|
||||
owner_pid = competing.get("session_pid") or competing.get("pid")
|
||||
raise RuntimeError(
|
||||
f"Issue #{issue_number} lock contention: {exc}; competing owner "
|
||||
f"pid={owner_pid} (fail closed)"
|
||||
) from exc
|
||||
raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc
|
||||
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:
|
||||
return assess_lock_freshness(lock, now=now)["live"]
|
||||
|
||||
|
||||
def assess_lock_freshness(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a lock as live, expired, stale, or absent."""
|
||||
current = _lease_now(now)
|
||||
if not lock_data:
|
||||
return {
|
||||
"status": "absent",
|
||||
"live": False,
|
||||
"stale": False,
|
||||
"reason": "no lock record",
|
||||
}
|
||||
|
||||
expires_at = lease_expires_at(lock_data)
|
||||
lease = lock_data.get("work_lease")
|
||||
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
|
||||
if heartbeat_at is None and isinstance(lease, dict):
|
||||
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
|
||||
|
||||
pid = lock_data.get("session_pid")
|
||||
if pid is None:
|
||||
pid = lock_data.get("pid")
|
||||
pid_alive = is_process_alive(pid) if pid is not None else False
|
||||
|
||||
if expires_at and expires_at <= current:
|
||||
return {
|
||||
"status": "expired",
|
||||
"live": False,
|
||||
"stale": True,
|
||||
"reason": f"lease expired at {expires_at.isoformat()}",
|
||||
"pid_alive": pid_alive,
|
||||
}
|
||||
|
||||
if pid is not None and not pid_alive:
|
||||
return {
|
||||
"status": "stale",
|
||||
"live": False,
|
||||
"stale": True,
|
||||
"reason": f"owner pid {pid} is not alive",
|
||||
"pid_alive": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "live",
|
||||
"live": True,
|
||||
"stale": False,
|
||||
"reason": "lock heartbeat and lease are fresh",
|
||||
"pid_alive": pid_alive,
|
||||
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def verify_lock_for_mutation(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
issue_number: int | None = None,
|
||||
branch_name: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Re-check lock ownership immediately before a mutation (#438)."""
|
||||
reasons: list[str] = []
|
||||
if not lock_data:
|
||||
return {"proven": False, "block": True, "reasons": ["issue lock is missing (fail closed)"]}
|
||||
|
||||
freshness = assess_lock_freshness(lock_data)
|
||||
if not freshness["live"]:
|
||||
reasons.append(f"issue lock is not live: {freshness['reason']} (fail closed)")
|
||||
|
||||
if issue_number is not None and lock_data.get("issue_number") != issue_number:
|
||||
reasons.append(
|
||||
f"issue lock targets #{lock_data.get('issue_number')}, expected #{issue_number} (fail closed)"
|
||||
)
|
||||
|
||||
if branch_name is not None and lock_data.get("branch_name") != branch_name:
|
||||
reasons.append(
|
||||
f"issue lock branch '{lock_data.get('branch_name')}' does not match "
|
||||
f"'{branch_name}' (fail closed)"
|
||||
)
|
||||
|
||||
if worktree_path is not None:
|
||||
locked = os.path.realpath(str(lock_data.get("worktree_path") or ""))
|
||||
declared = os.path.realpath(worktree_path)
|
||||
if locked != declared:
|
||||
reasons.append(
|
||||
f"issue lock worktree '{locked}' does not match declared '{declared}' (fail closed)"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"freshness": freshness,
|
||||
"lock_proof": format_lock_proof(lock_data, freshness=freshness),
|
||||
}
|
||||
|
||||
|
||||
def list_live_locks(
|
||||
*,
|
||||
lock_dir: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return live per-issue locks for queue visibility."""
|
||||
live: list[dict[str, Any]] = []
|
||||
for path in iter_lock_files(lock_dir):
|
||||
record = read_lock_file(path)
|
||||
if not record:
|
||||
continue
|
||||
freshness = assess_lock_freshness(record, now=now)
|
||||
if not freshness["live"]:
|
||||
continue
|
||||
live.append(
|
||||
{
|
||||
"issue_number": record.get("issue_number"),
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": record.get("remote"),
|
||||
"org": record.get("org"),
|
||||
"repo": record.get("repo"),
|
||||
"worktree_path": record.get("worktree_path"),
|
||||
"pid": record.get("session_pid") or record.get("pid"),
|
||||
"claimant": (
|
||||
record.get("claimant")
|
||||
or (record.get("work_lease") or {}).get("claimant")
|
||||
),
|
||||
"freshness": freshness,
|
||||
"lock_path": record.get("lock_file_path") or path,
|
||||
}
|
||||
)
|
||||
return live
|
||||
|
||||
|
||||
def format_lock_proof(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
freshness: dict[str, Any] | None = None,
|
||||
competing_live_locks: list[dict[str, Any]] | None = None,
|
||||
released: bool | None = None,
|
||||
) -> str:
|
||||
"""Canonical issue-lock proof string for final reports."""
|
||||
if not lock_data:
|
||||
return "issue lock proof: not acquired"
|
||||
fresh = freshness or assess_lock_freshness(lock_data)
|
||||
owner = lock_data.get("claimant") or {}
|
||||
if not owner and isinstance(lock_data.get("work_lease"), dict):
|
||||
owner = lock_data["work_lease"].get("claimant") or {}
|
||||
parts = [
|
||||
"issue lock proof:",
|
||||
f"acquired issue #{lock_data.get('issue_number')}",
|
||||
f"branch {lock_data.get('branch_name')}",
|
||||
f"owner {owner.get('profile') or 'unknown'}",
|
||||
f"pid {lock_data.get('session_pid') or lock_data.get('pid')}",
|
||||
f"freshness {fresh.get('status')}",
|
||||
]
|
||||
if competing_live_locks is not None:
|
||||
parts.append(
|
||||
"no competing live lock"
|
||||
if not competing_live_locks
|
||||
else f"competing live locks {len(competing_live_locks)}"
|
||||
)
|
||||
if released is True:
|
||||
parts.append("lock released")
|
||||
elif released is False:
|
||||
parts.append("lock retained")
|
||||
return "; ".join(parts)
|
||||
+5
-26
@@ -30,16 +30,8 @@ def resolve_author_worktree_path(
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
) -> dict:
|
||||
"""Read branch name and porcelain status from a git worktree.
|
||||
|
||||
``extra_bases`` names additional branches (e.g. an approved stacked base)
|
||||
that may anchor base-equivalence in addition to master/main/dev. When empty
|
||||
(the default), only the normal base branches are considered.
|
||||
"""
|
||||
def read_worktree_git_state(worktree_path: str) -> dict:
|
||||
"""Read branch name and porcelain status from a git worktree."""
|
||||
path = (worktree_path or "").strip()
|
||||
if not path:
|
||||
return {"current_branch": None, "porcelain_status": ""}
|
||||
@@ -71,7 +63,7 @@ def read_worktree_git_state(
|
||||
check=False,
|
||||
)
|
||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
||||
base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases)
|
||||
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
|
||||
return {
|
||||
"current_branch": current_branch,
|
||||
"porcelain_status": status_res.stdout or "",
|
||||
@@ -211,26 +203,13 @@ def _assessment(
|
||||
}
|
||||
|
||||
|
||||
def _find_matching_base_ref(
|
||||
path: str,
|
||||
head_sha: str | None,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return the stable branch ref whose commit matches HEAD, if any.
|
||||
|
||||
Normal base branches (master/main/dev) are always considered. ``extra_bases``
|
||||
adds explicitly-approved stacked bases; each is checked as a local ref and via
|
||||
the ``prgs``/``origin`` remotes.
|
||||
"""
|
||||
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
|
||||
"""Return the stable branch ref whose commit matches HEAD, if any."""
|
||||
if not head_sha:
|
||||
return None, None
|
||||
candidates: list[str] = []
|
||||
for branch in sorted(BASE_BRANCHES):
|
||||
candidates.extend((f"origin/{branch}", branch))
|
||||
for branch in extra_bases:
|
||||
name = (branch or "").strip()
|
||||
if name:
|
||||
candidates.extend((f"prgs/{name}", f"origin/{name}", name))
|
||||
for ref in candidates:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", ref],
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
"""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": [],
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Merge approval must pin the current PR head SHA (#471).
|
||||
|
||||
Formal APPROVED reviews that predate the live PR head must not satisfy
|
||||
``gitea_merge_pr`` eligibility. Pure assessment helpers are isolated here
|
||||
for hermetic unit tests apart from MCP HTTP calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def assess_merge_approval_head(
|
||||
*,
|
||||
current_head_sha: str | None,
|
||||
latest_by_reviewer: dict,
|
||||
) -> dict:
|
||||
"""Return whether a visible approval applies to the live PR head.
|
||||
|
||||
Args:
|
||||
current_head_sha: Current PR head commit SHA.
|
||||
latest_by_reviewer: Map of reviewer login → review entry dicts with
|
||||
``verdict``, ``dismissed``, and ``reviewed_head_sha`` keys.
|
||||
|
||||
Returns:
|
||||
dict with ``approval_at_current_head``, ``latest_approved_head_sha``,
|
||||
and ``stale_approval_block_reason`` (set when merge must fail closed).
|
||||
"""
|
||||
current = (current_head_sha or "").strip()
|
||||
approved_entries = [
|
||||
entry
|
||||
for entry in (latest_by_reviewer or {}).values()
|
||||
if (entry.get("verdict") or "").upper() == "APPROVED"
|
||||
and not entry.get("dismissed")
|
||||
]
|
||||
at_current = any(
|
||||
(entry.get("reviewed_head_sha") or "").strip() == current
|
||||
for entry in approved_entries
|
||||
if current
|
||||
)
|
||||
latest_approved = None
|
||||
if approved_entries:
|
||||
latest_entry = sorted(
|
||||
approved_entries,
|
||||
key=lambda entry: (
|
||||
entry.get("submitted_at") or "",
|
||||
entry.get("reviewed_head_sha") or "",
|
||||
),
|
||||
)[-1]
|
||||
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None
|
||||
reason = None
|
||||
if approved_entries and not at_current:
|
||||
reason = (
|
||||
f"stale approval: approved SHA '{latest_approved}' does not match "
|
||||
f"current live PR head SHA '{current or '(unknown)'}' (fail closed); "
|
||||
"required next action: re-review PR at current head before merge"
|
||||
)
|
||||
|
||||
return {
|
||||
"approval_at_current_head": at_current,
|
||||
"latest_approved_head_sha": latest_approved,
|
||||
"stale_approval_block_reason": reason,
|
||||
}
|
||||
+14
-10
@@ -13,9 +13,9 @@ import subprocess
|
||||
from typing import Any
|
||||
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
import issue_lock_store
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
|
||||
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@@ -37,18 +37,22 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
|
||||
|
||||
|
||||
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
||||
if path:
|
||||
return issue_lock_store.read_lock_file(path.strip())
|
||||
return issue_lock_store.read_session_issue_lock()
|
||||
lock_path = (path or ISSUE_LOCK_FILE).strip()
|
||||
if not lock_path or not os.path.exists(lock_path):
|
||||
return None
|
||||
try:
|
||||
with open(lock_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
|
||||
if lock_path:
|
||||
lock = issue_lock_store.read_lock_file(lock_path.strip())
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
return issue_lock_store.has_active_issue_lock(branch)
|
||||
lock = read_issue_lock(lock_path)
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
|
||||
|
||||
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"""Namespace-scoped MCP workspace binding (#510).
|
||||
|
||||
Each role namespace (author, reviewer, merger, reconciler) resolves its own
|
||||
active task workspace. Foreign role worktree environment variables must not
|
||||
poison workspace purity checks in another namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import author_mutation_worktree as amw
|
||||
|
||||
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
||||
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
||||
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
|
||||
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
|
||||
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||||
|
||||
ROLE_WORKTREE_ENVS: dict[str, str] = {
|
||||
"author": AUTHOR_WORKTREE_ENV,
|
||||
"reviewer": REVIEWER_WORKTREE_ENV,
|
||||
"merger": MERGER_WORKTREE_ENV,
|
||||
"reconciler": RECONCILER_WORKTREE_ENV,
|
||||
}
|
||||
|
||||
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
|
||||
|
||||
|
||||
def normalize_role_kind(
|
||||
role_kind: str | None,
|
||||
*,
|
||||
profile_name: str | None = None,
|
||||
) -> str:
|
||||
"""Map profile/task role to a workspace namespace key."""
|
||||
role = (role_kind or "author").strip().lower()
|
||||
profile = (profile_name or "").strip().lower()
|
||||
if role == "reviewer" and "merger" in profile:
|
||||
return "merger"
|
||||
if role in ROLE_WORKTREE_ENVS:
|
||||
return role
|
||||
return "author"
|
||||
|
||||
|
||||
def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None:
|
||||
text = (env.get(key) or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def resolve_namespace_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None = None,
|
||||
worktree: str | None = None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
for candidate, source in (
|
||||
(worktree_path, "worktree_path argument"),
|
||||
(worktree, "worktree argument"),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree"),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text)), source
|
||||
|
||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||
|
||||
|
||||
def resolve_namespace_mutation_context(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
*,
|
||||
role_kind: str,
|
||||
resolved_workspace: str,
|
||||
binding_source: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Detect when a foreign role env would have hijacked workspace binding."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if role == "author":
|
||||
return {"would_pollute": False, "ignored_bindings": []}
|
||||
|
||||
ignored: list[str] = []
|
||||
author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV)
|
||||
if author_path:
|
||||
author_real = os.path.realpath(os.path.abspath(author_path))
|
||||
resolved_real = os.path.realpath(resolved_workspace)
|
||||
if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable":
|
||||
ignored.append(
|
||||
f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)"
|
||||
)
|
||||
return {
|
||||
"would_pollute": bool(ignored),
|
||||
"ignored_bindings": ignored,
|
||||
}
|
||||
|
||||
|
||||
def assess_metadata_only_worktree_binding(
|
||||
*,
|
||||
role_kind: str,
|
||||
declared_worktree_path: str | None,
|
||||
mutation_workspace: str,
|
||||
process_project_root: str,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when declared worktree_path would not redirect mutations."""
|
||||
declared = (declared_worktree_path or "").strip()
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
mutation_root = os.path.realpath(mutation_workspace)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if not declared:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
declared_root = os.path.realpath(os.path.abspath(declared))
|
||||
if declared_root == mutation_root:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
if declared_root != process_root and mutation_root == process_root:
|
||||
return {
|
||||
"block": True,
|
||||
"metadata_only": True,
|
||||
"reasons": [
|
||||
f"worktree_path is metadata-only for {role} mutations: preflight "
|
||||
f"inspected '{declared_root}' but mutation tools would still "
|
||||
f"validate MCP server process root '{process_root}'"
|
||||
],
|
||||
"declared_worktree_path": declared_root,
|
||||
"mutation_workspace": mutation_root,
|
||||
"process_project_root": process_root,
|
||||
}
|
||||
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
|
||||
def format_namespace_workspace_binding_error(
|
||||
*,
|
||||
role_kind: str,
|
||||
workspace_path: str,
|
||||
binding_source: str,
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
]
|
||||
if ignored_bindings:
|
||||
parts.append(
|
||||
"Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "."
|
||||
)
|
||||
if dirty_files:
|
||||
parts.append(
|
||||
"Dirty tracked files in active task workspace: "
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def assess_namespace_mutation_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
worktree: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||
ctx = resolve_namespace_mutation_context(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
mutation_workspace = ctx["workspace_path"]
|
||||
binding_source = ctx["workspace_binding_source"]
|
||||
role = ctx["workspace_role_kind"]
|
||||
process_root = ctx["process_project_root"]
|
||||
|
||||
metadata = assess_metadata_only_worktree_binding(
|
||||
role_kind=role,
|
||||
declared_worktree_path=worktree_path,
|
||||
mutation_workspace=mutation_workspace,
|
||||
process_project_root=process_root,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=mutation_workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||
f"create or reconnect to a session-owned worktree under branches/ "
|
||||
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
|
||||
)
|
||||
elif (
|
||||
role in {"reviewer", "merger"}
|
||||
and mutation_workspace != process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
||||
f"'{ctx['canonical_repo_root']}/branches/'"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"mutation_workspace": mutation_workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": ctx["canonical_repo_root"],
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
||||
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
||||
|
||||
_CLEANUP_SECTION_HINT = re.compile(
|
||||
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
||||
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
||||
_CLEANUP_BLOCKER_RE = re.compile(
|
||||
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
||||
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
||||
r"deleted remote branch|delete_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
||||
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
||||
r"worktree cleanup performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
||||
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
||||
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_TASK_RE = re.compile(
|
||||
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_HEAD_BRANCH_RE = re.compile(
|
||||
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
||||
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OPEN_PR_INVENTORY_RE = re.compile(
|
||||
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
||||
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
||||
r"no other open pr references branch",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
||||
r"(?:no active (?:heartbeat|claim|lease)|"
|
||||
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
||||
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_LIST_AFTER_RE = re.compile(
|
||||
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _claims_remote_delete(text: str) -> bool:
|
||||
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _claims_worktree_remove(text: str) -> bool:
|
||||
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _branch_safety_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
||||
if not _DELETE_TASK_RE.search(text):
|
||||
missing.append("delete-branch task named (delete_branch or cleanup)")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
missing.append("merge result: merged")
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
missing.append("merge commit SHA")
|
||||
if not _PR_HEAD_BRANCH_RE.search(text):
|
||||
missing.append("merged PR head branch / deleted branch name")
|
||||
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
||||
missing.append("branch not protected proof")
|
||||
if not _OPEN_PR_INVENTORY_RE.search(text):
|
||||
missing.append("open PR inventory proof (no other PR references branch)")
|
||||
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
||||
missing.append("no active heartbeat/claim/lease proof")
|
||||
return missing
|
||||
|
||||
|
||||
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
||||
path = match.group(1).strip() if match else ""
|
||||
if not path:
|
||||
missing.append("session-owned worktree path")
|
||||
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
||||
missing.append("worktree path under branches/")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("pre-removal tracked state: clean")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("pre-removal untracked state: clean")
|
||||
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
||||
missing.append("git worktree list after removal")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_post_merge_cleanup_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
cleanup_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
||||
text = report_text or ""
|
||||
session = dict(cleanup_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
||||
blocker = (session.get("blocker") or "").strip()
|
||||
if not blocker:
|
||||
match = _CLEANUP_BLOCKER_RE.search(text)
|
||||
blocker = match.group(1).strip() if match else ""
|
||||
if blocker.upper() == CLEANUP_SKIPPED:
|
||||
blocker = ""
|
||||
if not blocker:
|
||||
reasons.append(
|
||||
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"proven": not reasons,
|
||||
"outcome": CLEANUP_SKIPPED,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"outcome": None,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
remote_delete = bool(
|
||||
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
||||
)
|
||||
worktree_remove = bool(
|
||||
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
||||
)
|
||||
|
||||
if _WRONG_BRANCH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup report claims deleted branch that is not the merged PR head branch"
|
||||
)
|
||||
|
||||
if remote_delete:
|
||||
reasons.extend(
|
||||
f"remote branch deletion missing {field}"
|
||||
for field in _branch_safety_fields_present(text)
|
||||
)
|
||||
|
||||
if worktree_remove:
|
||||
reasons.extend(
|
||||
f"worktree removal missing {field}"
|
||||
for field in _worktree_cleanup_fields_present(text)
|
||||
)
|
||||
|
||||
if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove):
|
||||
pass
|
||||
|
||||
if not remote_delete and not worktree_remove:
|
||||
cleanup_mutations = re.search(
|
||||
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if cleanup_mutations:
|
||||
reasons.append(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
||||
if remote_delete or worktree_remove:
|
||||
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"outcome": outcome,
|
||||
"remote_delete_claimed": remote_delete,
|
||||
"worktree_remove_claimed": worktree_remove,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
||||
"checklist before claiming remote delete or worktree removal"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
"""Conflict-fix and reviewer PR work leases (#399, #407 reader).
|
||||
|
||||
Structured PR/issue comments prove exclusive phases so author conflict-fix
|
||||
pushes cannot race reviewer validation/approval/merge on the same head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
REVIEWER_LEASE_MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||
CONFLICT_FIX_LEASE_MARKER = "<!-- mcp-conflict-fix-lease:v1 -->"
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
|
||||
_ACTIVE_REVIEWER_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
"approved",
|
||||
"request-changes",
|
||||
"merging",
|
||||
})
|
||||
_TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||
|
||||
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
||||
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
return text if _FULL_SHA.match(text) else None
|
||||
|
||||
|
||||
def _parse_pr_ref(value: str | None) -> int | None:
|
||||
digits = re.sub(r"[^\d]", "", value or "")
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def _parse_marker_comment(body: str, marker: str) -> dict[str, str] | None:
|
||||
text = body or ""
|
||||
if marker not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
return fields or None
|
||||
|
||||
|
||||
def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
fields = _parse_marker_comment(body, REVIEWER_LEASE_MARKER)
|
||||
if not fields:
|
||||
return None
|
||||
return {
|
||||
"lease_kind": "reviewer",
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||
"reviewer_identity": fields.get("reviewer_identity"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"candidate_head": _normalize_sha(fields.get("candidate_head")),
|
||||
"target_branch": fields.get("target_branch"),
|
||||
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
|
||||
"last_activity": fields.get("last_activity"),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def parse_conflict_fix_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
fields = _parse_marker_comment(body, CONFLICT_FIX_LEASE_MARKER)
|
||||
if not fields:
|
||||
return None
|
||||
ff = (fields.get("fast_forward") or "").strip().lower()
|
||||
reviewer_active = (fields.get("reviewer_active") or "").strip().lower()
|
||||
return {
|
||||
"lease_kind": "conflict_fix",
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"branch": fields.get("branch"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"head_before": _normalize_sha(fields.get("head_before")),
|
||||
"head_after": _normalize_sha(fields.get("head_after")),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"reviewer_active": reviewer_active in {"yes", "true", "1"},
|
||||
"fast_forward": ff in {"yes", "true", "1"},
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def _comment_entries(comments: list[dict], *, pr_number: int | None) -> list[dict]:
|
||||
entries: list[dict] = []
|
||||
for comment in comments or []:
|
||||
body = comment.get("body") or ""
|
||||
for parser in (parse_reviewer_lease_comment, parse_conflict_fix_lease_comment):
|
||||
parsed = parser(body)
|
||||
if not parsed:
|
||||
continue
|
||||
if pr_number is not None and parsed.get("pr_number") not in (None, pr_number):
|
||||
continue
|
||||
entries.append({
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
})
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def _lease_expired(lease: dict, *, now: datetime) -> bool:
|
||||
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||
return bool(expires_at and expires_at <= now)
|
||||
|
||||
|
||||
def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES or phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
return False
|
||||
return phase in active_phases or bool(phase and phase not in (
|
||||
_TERMINAL_REVIEWER_PHASES | _TERMINAL_CONFLICT_FIX_PHASES
|
||||
))
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "reviewer"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_REVIEWER_PHASES or phase:
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def find_active_conflict_fix_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired conflict-fix lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "conflict_fix"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase:
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def format_conflict_fix_lease_body(
|
||||
*,
|
||||
pr_number: int,
|
||||
branch: str,
|
||||
worktree: str,
|
||||
profile: str,
|
||||
head_before: str,
|
||||
phase: str = "claimed",
|
||||
session_id: str = "unknown",
|
||||
expires_at: datetime | None = None,
|
||||
reviewer_active: bool = False,
|
||||
) -> str:
|
||||
expires = expires_at or (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=DEFAULT_CONFLICT_FIX_TTL_MINUTES)
|
||||
)
|
||||
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
lines = [
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
f"pr: #{pr_number}",
|
||||
f"branch: {branch}",
|
||||
f"worktree: {worktree}",
|
||||
f"profile: {profile}",
|
||||
f"session_id: {session_id}",
|
||||
f"phase: {phase}",
|
||||
f"head_before: {head_before}",
|
||||
f"expires_at: {expires_text}",
|
||||
f"reviewer_active: {'yes' if reviewer_active else 'no'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_head_sha_equality(
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when reviewed and live PR heads differ."""
|
||||
reviewed = _normalize_sha(reviewed_head_sha)
|
||||
live = _normalize_sha(live_head_sha)
|
||||
reasons: list[str] = []
|
||||
if not reviewed or not live:
|
||||
reasons.append(
|
||||
"reviewed/live head SHA missing or not full 40-hex; fail closed"
|
||||
)
|
||||
elif reviewed != live:
|
||||
reasons.append(
|
||||
"PR head changed after validation; re-pin and re-validate before "
|
||||
"approval or merge"
|
||||
)
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"reviewed_head_sha": reviewed,
|
||||
"live_head_sha": live,
|
||||
"head_changed": bool(reviewed and live and reviewed != live),
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_push(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
branch_head_before: str | None,
|
||||
branch_head_after: str | None,
|
||||
worktree_path: str | None,
|
||||
push_cwd: str | None,
|
||||
is_fast_forward: bool | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Author pre-push gate: block when a reviewer holds an active lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
reviewer_lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
|
||||
|
||||
if reviewer_lease:
|
||||
reasons.append(
|
||||
f"active reviewer lease on PR #{pr_number} "
|
||||
f"(phase={reviewer_lease.get('phase')}); author push blocked"
|
||||
)
|
||||
|
||||
head_before = _normalize_sha(branch_head_before)
|
||||
head_after = _normalize_sha(branch_head_after)
|
||||
if not head_before:
|
||||
reasons.append("branch head before push missing or invalid SHA")
|
||||
if head_after and head_before and head_before == head_after:
|
||||
reasons.append("branch head unchanged; no push to perform")
|
||||
|
||||
worktree = (worktree_path or "").strip()
|
||||
cwd = (push_cwd or "").strip()
|
||||
if not worktree:
|
||||
reasons.append("worktree path required for conflict-fix push proof")
|
||||
elif cwd and worktree and not cwd.rstrip("/").endswith(worktree.rstrip("/").split("/")[-1]):
|
||||
if worktree not in cwd:
|
||||
reasons.append(
|
||||
f"push cwd '{cwd}' does not match session worktree '{worktree}'"
|
||||
)
|
||||
|
||||
if is_fast_forward is False:
|
||||
reasons.append("non-fast-forward push rejected for conflict-fix (fail closed)")
|
||||
|
||||
if conflict_lease and conflict_lease.get("phase") == "pushing":
|
||||
owner = conflict_lease.get("worktree")
|
||||
if owner and worktree and owner != worktree:
|
||||
reasons.append(
|
||||
f"sibling conflict-fix lease active from worktree '{owner}'"
|
||||
)
|
||||
|
||||
push_allowed = not reasons
|
||||
return {
|
||||
"push_allowed": push_allowed,
|
||||
"block": not push_allowed,
|
||||
"reasons": reasons,
|
||||
"active_reviewer_lease": reviewer_lease,
|
||||
"active_conflict_fix_lease": conflict_lease,
|
||||
"branch_head_before": head_before,
|
||||
"branch_head_after": head_after,
|
||||
"reviewer_was_active": bool(reviewer_lease),
|
||||
"fast_forward": is_fast_forward,
|
||||
}
|
||||
|
||||
|
||||
def assess_reviewer_mutation_blocked(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
mutation: str,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer gate: block when conflict-fix lease active or head moved."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
|
||||
if conflict_lease and (conflict_lease.get("phase") or "") in _ACTIVE_CONFLICT_FIX_PHASES:
|
||||
reasons.append(
|
||||
f"active conflict-fix lease on PR #{pr_number} "
|
||||
f"(phase={conflict_lease.get('phase')}); reviewer {mutation} blocked"
|
||||
)
|
||||
|
||||
head_check = assess_head_sha_equality(reviewed_head_sha, live_head_sha)
|
||||
if head_check["block"]:
|
||||
reasons.extend(head_check["reasons"])
|
||||
|
||||
if not _normalize_sha(reviewed_head_sha):
|
||||
reasons.append(
|
||||
f"reviewed head SHA required before reviewer {mutation} (fail closed)"
|
||||
)
|
||||
|
||||
allowed = not reasons
|
||||
return {
|
||||
"mutation_allowed": allowed,
|
||||
"block": not allowed,
|
||||
"reasons": reasons,
|
||||
"active_conflict_fix_lease": conflict_lease,
|
||||
"head_check": head_check,
|
||||
"reviewed_head_sha": head_check.get("reviewed_head_sha"),
|
||||
"live_head_sha": head_check.get("live_head_sha"),
|
||||
"push_during_validation": bool(
|
||||
conflict_lease and conflict_lease.get("phase") in {"pushing", "pushed"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_REVIEWED_HEAD_RE = re.compile(
|
||||
r"reviewed head sha\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_BEFORE_APPROVAL_RE = re.compile(
|
||||
r"(?:live head sha before approval|final live head sha before approval)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_BEFORE_MERGE_RE = re.compile(
|
||||
r"(?:live head sha before merge|final live head sha before merge)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PUSH_DURING_VALIDATION_RE = re.compile(
|
||||
r"push(?:es)? occurred during validation\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_HEAD_BEFORE_RE = re.compile(
|
||||
r"branch head before push\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_HEAD_AFTER_RE = re.compile(
|
||||
r"branch head after push\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWER_LEASE_STATUS_RE = re.compile(
|
||||
r"active reviewer lease status\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FAST_FORWARD_RE = re.compile(
|
||||
r"whether push was fast-forward\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWER_ACTIVE_RE = re.compile(
|
||||
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
||||
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
|
||||
live_approval = _normalize_sha(
|
||||
_LIVE_HEAD_BEFORE_APPROVAL_RE.search(text).group(1)
|
||||
if _LIVE_HEAD_BEFORE_APPROVAL_RE.search(text)
|
||||
else None
|
||||
)
|
||||
live_merge = _normalize_sha(
|
||||
_LIVE_HEAD_BEFORE_MERGE_RE.search(text).group(1)
|
||||
if _LIVE_HEAD_BEFORE_MERGE_RE.search(text)
|
||||
else None
|
||||
)
|
||||
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
|
||||
|
||||
if not reviewed:
|
||||
reasons.append("reviewed head SHA not stated in final report")
|
||||
if not live_approval:
|
||||
reasons.append("final live head SHA before approval not stated")
|
||||
if not live_merge:
|
||||
reasons.append("final live head SHA before merge not stated")
|
||||
if not push_during:
|
||||
reasons.append("whether push occurred during validation not stated")
|
||||
elif reviewed and live_approval and reviewed != live_approval:
|
||||
reasons.append("live head before approval differs from reviewed head SHA")
|
||||
elif reviewed and live_merge and reviewed != live_merge:
|
||||
reasons.append("live head before merge differs from reviewed head SHA")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"reviewed_head_sha": reviewed,
|
||||
"live_head_sha_before_approval": live_approval,
|
||||
"live_head_sha_before_merge": live_merge,
|
||||
"push_during_validation": (push_during.group(1).lower() if push_during else None),
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_final_report(report_text: str) -> dict[str, Any]:
|
||||
"""Final-report proof for conflict-fix push sessions (#399 AC 7)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
head_before = _normalize_sha(
|
||||
_CONFLICT_HEAD_BEFORE_RE.search(text).group(1)
|
||||
if _CONFLICT_HEAD_BEFORE_RE.search(text)
|
||||
else None
|
||||
)
|
||||
head_after = _normalize_sha(
|
||||
_CONFLICT_HEAD_AFTER_RE.search(text).group(1)
|
||||
if _CONFLICT_HEAD_AFTER_RE.search(text)
|
||||
else None
|
||||
)
|
||||
if not head_before:
|
||||
reasons.append("branch head before push not stated")
|
||||
if not head_after:
|
||||
reasons.append("branch head after push not stated")
|
||||
if not _REVIEWER_LEASE_STATUS_RE.search(text):
|
||||
reasons.append("active reviewer lease status not stated")
|
||||
if not _FAST_FORWARD_RE.search(text):
|
||||
reasons.append("whether push was fast-forward not stated")
|
||||
if not _REVIEWER_ACTIVE_RE.search(text):
|
||||
reasons.append("whether any reviewer was active not stated")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"branch_head_before": head_before,
|
||||
"branch_head_after": head_after,
|
||||
}
|
||||
@@ -3624,12 +3624,9 @@ def assess_work_issue_mode_isolation(report_text: str) -> dict:
|
||||
|
||||
def assess_work_issue_final_report(report_text: str) -> dict:
|
||||
"""#139: composite verifier for work-issue final reports."""
|
||||
from issue_work_duplicate_gate import assess_work_issue_duplicate_report
|
||||
|
||||
checks = {
|
||||
"workflow_source": assess_work_issue_workflow_source(report_text),
|
||||
"mode_isolation": assess_work_issue_mode_isolation(report_text),
|
||||
"duplicate_work_outcome": assess_work_issue_duplicate_report(report_text),
|
||||
}
|
||||
|
||||
reasons = []
|
||||
@@ -5515,13 +5512,6 @@ def assess_validation_failure_history_report(report_text, **kwargs):
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_validation_cwd_proof_report(report_text, **kwargs):
|
||||
"""#398: validation commands require explicit worktree cwd and HEAD proof."""
|
||||
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report as _assess
|
||||
|
||||
return _assess(report_text, **kwargs)
|
||||
|
||||
|
||||
def assess_already_landed_classification_report(report_text, **kwargs):
|
||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
||||
from reviewer_already_landed_classification import (
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
"""Per-PR reviewer leases for safe parallel review sessions (#407)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
|
||||
_ACTIVE_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
"approved",
|
||||
"request-changes",
|
||||
"merging",
|
||||
})
|
||||
|
||||
DEFAULT_LEASE_TTL_MINUTES = 120
|
||||
STALE_WARNING_MINUTES = 30
|
||||
RECLAIMABLE_MINUTES = 60
|
||||
|
||||
_SESSION_LEASE: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
return text if text and _FULL_SHA.match(text) else None
|
||||
|
||||
|
||||
def _parse_pr_ref(value: str | None) -> int | None:
|
||||
digits = re.sub(r"[^\d]", "", value or "")
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def new_session_id() -> str:
|
||||
return f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def format_lease_body(
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
issue_number: int | None,
|
||||
reviewer_identity: str,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
worktree: str,
|
||||
phase: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
last_activity: datetime | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
blocker: str = "none",
|
||||
) -> str:
|
||||
now = last_activity or datetime.now(timezone.utc)
|
||||
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
|
||||
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
issue_text = f"#{issue_number}" if issue_number else "none"
|
||||
lines = [
|
||||
MARKER,
|
||||
f"repo: {repo}",
|
||||
f"pr: #{pr_number}",
|
||||
f"issue: {issue_text}",
|
||||
f"reviewer_identity: {reviewer_identity}",
|
||||
f"profile: {profile}",
|
||||
f"session_id: {session_id}",
|
||||
f"worktree: {worktree}",
|
||||
f"phase: {phase}",
|
||||
f"candidate_head: {candidate_head or 'none'}",
|
||||
f"target_branch: {target_branch}",
|
||||
f"target_branch_sha: {target_branch_sha or 'none'}",
|
||||
f"last_activity: {last_text}",
|
||||
f"expires_at: {expires_text}",
|
||||
f"blocker: {blocker}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
text = body or ""
|
||||
if MARKER not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
if not fields:
|
||||
return None
|
||||
return {
|
||||
"repo": fields.get("repo"),
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||
"reviewer_identity": fields.get("reviewer_identity"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"candidate_head": _normalize_sha(fields.get("candidate_head")),
|
||||
"target_branch": fields.get("target_branch"),
|
||||
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
|
||||
"last_activity": fields.get("last_activity"),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def _lease_entries(comments: list[dict], *, pr_number: int) -> list[dict]:
|
||||
entries: list[dict] = []
|
||||
for comment in comments or []:
|
||||
parsed = parse_lease_comment(comment.get("body") or "")
|
||||
if not parsed:
|
||||
continue
|
||||
if parsed.get("pr_number") not in (None, pr_number):
|
||||
continue
|
||||
entries.append({
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def _lease_expired(lease: dict, *, now: datetime) -> bool:
|
||||
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||
return bool(expires_at and expires_at <= now)
|
||||
|
||||
|
||||
def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
|
||||
last = _parse_timestamp(lease.get("last_activity"))
|
||||
if not last:
|
||||
return None
|
||||
return (now - last).total_seconds() / 60.0
|
||||
|
||||
|
||||
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
|
||||
"""Return active, stale_warning, reclaimable, expired, or terminal."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
return "terminal"
|
||||
if _lease_expired(lease, now=now):
|
||||
return "expired"
|
||||
minutes = _minutes_since_activity(lease, now=now)
|
||||
if minutes is None:
|
||||
return "active"
|
||||
if minutes >= RECLAIMABLE_MINUTES:
|
||||
return "reclaimable"
|
||||
if minutes >= STALE_WARNING_MINUTES:
|
||||
return "stale_warning"
|
||||
return "active"
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Newest non-terminal, unexpired lease for *pr_number*."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
for lease in reversed(_lease_entries(comments, pr_number=pr_number)):
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_PHASES:
|
||||
continue
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
if phase in _ACTIVE_PHASES or phase:
|
||||
lease = dict(lease)
|
||||
lease["freshness"] = classify_lease_freshness(lease, now=now)
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def assess_acquire_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
reviewer_identity: str,
|
||||
profile: str,
|
||||
session_id: str,
|
||||
repo: str,
|
||||
issue_number: int | None,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when another session holds an active lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
if existing:
|
||||
owner_session = (existing.get("session_id") or "").strip()
|
||||
freshness = existing.get("freshness") or classify_lease_freshness(existing, now=now)
|
||||
if owner_session and owner_session != session_id and freshness in {
|
||||
"active", "stale_warning"
|
||||
}:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} already has active reviewer lease "
|
||||
f"(session_id={owner_session}, phase={existing.get('phase')})"
|
||||
)
|
||||
elif owner_session and owner_session != session_id and freshness == "reclaimable":
|
||||
reasons.append(
|
||||
f"PR #{pr_number} lease is reclaimable but still held by "
|
||||
f"session_id={owner_session}; explicit reclaim not implemented "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
if not (reviewer_identity or "").strip():
|
||||
reasons.append("reviewer identity required for lease acquisition")
|
||||
if not (session_id or "").strip():
|
||||
reasons.append("session_id required for lease acquisition")
|
||||
if not (worktree or "").strip():
|
||||
reasons.append("worktree path required for lease acquisition")
|
||||
|
||||
allowed = not reasons
|
||||
body = None
|
||||
if allowed:
|
||||
body = format_lease_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number,
|
||||
reviewer_identity=reviewer_identity,
|
||||
profile=profile,
|
||||
session_id=session_id,
|
||||
worktree=worktree,
|
||||
phase="claimed",
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
last_activity=now,
|
||||
)
|
||||
return {
|
||||
"acquire_allowed": allowed,
|
||||
"reasons": reasons,
|
||||
"existing_lease": existing,
|
||||
"lease_body": body,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
|
||||
def record_session_lease(lease: dict[str, Any]) -> dict[str, Any]:
|
||||
global _SESSION_LEASE
|
||||
_SESSION_LEASE = dict(lease)
|
||||
return dict(_SESSION_LEASE)
|
||||
|
||||
|
||||
def clear_session_lease() -> None:
|
||||
global _SESSION_LEASE
|
||||
_SESSION_LEASE = None
|
||||
|
||||
|
||||
def get_session_lease() -> dict[str, Any] | None:
|
||||
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
|
||||
|
||||
|
||||
def assess_mutation_lease_gate(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
reviewer_identity: str,
|
||||
session_id: str | None,
|
||||
mutation: str,
|
||||
live_head_sha: str | None,
|
||||
pinned_head_sha: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer mutations require an owned, current PR lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
session = get_session_lease()
|
||||
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
|
||||
if not session:
|
||||
reasons.append(
|
||||
f"no in-session reviewer lease recorded; acquire via "
|
||||
f"gitea_acquire_reviewer_pr_lease before {mutation}"
|
||||
)
|
||||
elif session.get("pr_number") != pr_number:
|
||||
reasons.append(
|
||||
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
|
||||
)
|
||||
elif (session.get("session_id") or "") != (session_id or session.get("session_id")):
|
||||
reasons.append("session lease session_id mismatch (fail closed)")
|
||||
|
||||
if active:
|
||||
owner = (active.get("session_id") or "").strip()
|
||||
if owner and session_id and owner != session_id:
|
||||
reasons.append(
|
||||
f"active PR lease owned by session_id={owner}; current session "
|
||||
f"cannot {mutation}"
|
||||
)
|
||||
pinned = _normalize_sha(pinned_head_sha)
|
||||
live = _normalize_sha(live_head_sha)
|
||||
lease_head = active.get("candidate_head")
|
||||
if pinned and live and pinned != live:
|
||||
reasons.append(
|
||||
"PR head changed during lease; stop and re-validate before "
|
||||
f"reviewer {mutation}"
|
||||
)
|
||||
if lease_head and live and lease_head != live:
|
||||
reasons.append(
|
||||
"live PR head differs from lease candidate_head; refresh lease "
|
||||
f"before {mutation}"
|
||||
)
|
||||
freshness = active.get("freshness") or classify_lease_freshness(active, now=now)
|
||||
if freshness in {"expired", "reclaimable"}:
|
||||
reasons.append(f"reviewer lease freshness is '{freshness}' (fail closed)")
|
||||
else:
|
||||
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
|
||||
|
||||
allowed = not reasons
|
||||
return {
|
||||
"mutation_allowed": allowed,
|
||||
"block": not allowed,
|
||||
"reasons": reasons,
|
||||
"active_lease": active,
|
||||
"session_lease": session,
|
||||
}
|
||||
|
||||
|
||||
def assess_lease_inventory(
|
||||
comments_by_pr: dict[int, list[dict]],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize lease states across PR comment threads."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
active: list[dict] = []
|
||||
stale: list[dict] = []
|
||||
reclaimable: list[dict] = []
|
||||
for pr_number, comments in (comments_by_pr or {}).items():
|
||||
lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
if not lease:
|
||||
continue
|
||||
freshness = lease.get("freshness") or classify_lease_freshness(lease, now=now)
|
||||
entry = {"pr_number": pr_number, "session_id": lease.get("session_id"), "freshness": freshness}
|
||||
if freshness == "stale_warning":
|
||||
stale.append(entry)
|
||||
elif freshness == "reclaimable":
|
||||
reclaimable.append(entry)
|
||||
else:
|
||||
active.append(entry)
|
||||
return {
|
||||
"active_review_leases": active,
|
||||
"stale_review_leases": stale,
|
||||
"reclaimable_review_leases": reclaimable,
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Explicit worktree and cwd proof for PR review validation (#398)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_PWD_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:pwd|working\s+directory|cwd)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_HEAD_RE = re.compile(
|
||||
r"(?:git\s+rev-parse\s+head|observed\s+head\s+sha)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_EXPECTED_HEAD_RE = re.compile(
|
||||
r"(?:expected\s+(?:pr\s+)?head\s+sha|candidate\s+head\s+sha|pinned\s+head)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_STATUS_RE = re.compile(
|
||||
r"git\s+status\s+(?:--short\s+--branch|--short|-sb)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_CMD_RE = re.compile(
|
||||
r"validation\s+command\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GIT_C_CMD_RE = re.compile(r"git\s+-C\s+\S+", re.IGNORECASE)
|
||||
_CD_CMD_RE = re.compile(r"(?:^|&&\s*)cd\s+\S+", re.IGNORECASE)
|
||||
_BASELINE_CWD_RE = re.compile(
|
||||
r"baseline\s+(?:worktree|working\s+directory|cwd)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_SHA_RE = re.compile(
|
||||
r"baseline\s+(?:target\s+)?sha\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_CMD_RE = re.compile(
|
||||
r"baseline\s+validation\s+command\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def _path_under_branches(path: str, project_root: str | None = None) -> bool:
|
||||
normalized = _normalize_path(path)
|
||||
if not normalized:
|
||||
return False
|
||||
if "/branches/" in f"{normalized}/":
|
||||
return True
|
||||
if normalized.endswith("/branches"):
|
||||
return True
|
||||
if project_root:
|
||||
root = _normalize_path(project_root)
|
||||
if normalized.startswith(f"{root}/"):
|
||||
rel = normalized[len(root) + 1 :]
|
||||
return rel == "branches" or rel.startswith("branches/")
|
||||
return False
|
||||
|
||||
|
||||
def _expand_sha(sha: str) -> str:
|
||||
return (sha or "").strip().lower()
|
||||
|
||||
|
||||
def _sha_matches(expected: str, observed: str) -> bool:
|
||||
exp = _expand_sha(expected)
|
||||
obs = _expand_sha(observed)
|
||||
if not exp or not obs:
|
||||
return False
|
||||
if len(exp) == 40 and len(obs) == 40:
|
||||
return exp == obs
|
||||
return obs.startswith(exp) or exp.startswith(obs)
|
||||
|
||||
|
||||
def _command_has_explicit_cwd(command: str, cwd: str) -> bool:
|
||||
text = (command or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if _GIT_C_CMD_RE.search(text):
|
||||
return True
|
||||
if _CD_CMD_RE.search(text):
|
||||
return True
|
||||
if cwd and cwd in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assess_validation_cwd_proof_report(
|
||||
report_text: str,
|
||||
*,
|
||||
validation_session: dict | None = None,
|
||||
project_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Require cwd/HEAD proof before reviewer validation claims (#398)."""
|
||||
text = report_text or ""
|
||||
session = dict(validation_session or {})
|
||||
reasons: list[str] = []
|
||||
violations: list[str] = []
|
||||
|
||||
claims_validation = bool(
|
||||
session.get("validation_ran")
|
||||
or _VALIDATION_CMD_RE.search(text)
|
||||
or session.get("command")
|
||||
)
|
||||
if not claims_validation:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"claims_validation": False,
|
||||
"reasons": [],
|
||||
"violations": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
expected_head = (
|
||||
session.get("expected_head_sha")
|
||||
or session.get("candidate_head_sha")
|
||||
or ""
|
||||
).strip()
|
||||
if not expected_head:
|
||||
match = _EXPECTED_HEAD_RE.search(text)
|
||||
expected_head = (match.group(1) if match else "").strip()
|
||||
|
||||
observed_head = (session.get("observed_head_sha") or "").strip()
|
||||
if not observed_head:
|
||||
match = _HEAD_RE.search(text)
|
||||
observed_head = (match.group(1) if match else "").strip()
|
||||
|
||||
cwd = (
|
||||
session.get("working_directory")
|
||||
or session.get("cwd")
|
||||
or session.get("pwd")
|
||||
or ""
|
||||
).strip()
|
||||
if not cwd:
|
||||
match = _PWD_RE.search(text)
|
||||
cwd = (match.group(1) if match else "").strip().rstrip(",.;")
|
||||
|
||||
command = (session.get("command") or "").strip()
|
||||
if not command:
|
||||
match = _VALIDATION_CMD_RE.search(text)
|
||||
command = (match.group(1) if match else "").strip().rstrip(".;")
|
||||
|
||||
if not cwd:
|
||||
reasons.append(
|
||||
"validation claimed without pwd/working-directory proof (#398)"
|
||||
)
|
||||
elif not _path_under_branches(cwd, project_root):
|
||||
violations.append(
|
||||
f"validation cwd {cwd!r} is not under branches/ (#398)"
|
||||
)
|
||||
reasons.append(
|
||||
"reviewer validation must run from a branches/ worktree, "
|
||||
"not the main checkout (#398)"
|
||||
)
|
||||
|
||||
if not observed_head:
|
||||
reasons.append(
|
||||
"validation claimed without git rev-parse HEAD / observed HEAD SHA "
|
||||
"proof (#398)"
|
||||
)
|
||||
elif expected_head and not _sha_matches(expected_head, observed_head):
|
||||
violations.append(
|
||||
f"observed HEAD {observed_head} does not match expected "
|
||||
f"PR head {expected_head} (#398)"
|
||||
)
|
||||
reasons.append("validation HEAD SHA must match pinned PR head (#398)")
|
||||
|
||||
if not _STATUS_RE.search(text) and session.get("git_status") is None:
|
||||
reasons.append(
|
||||
"validation claimed without git status --short --branch proof (#398)"
|
||||
)
|
||||
|
||||
if command and cwd and not _command_has_explicit_cwd(command, cwd):
|
||||
if session.get("tool_working_directory") is not True:
|
||||
reasons.append(
|
||||
"validation command must use git -C <worktree>, "
|
||||
"cd <worktree> && ..., or tool-provided cwd metadata (#398)"
|
||||
)
|
||||
|
||||
baseline_ran = bool(
|
||||
session.get("baseline_validation_ran")
|
||||
or _BASELINE_CMD_RE.search(text)
|
||||
)
|
||||
if baseline_ran:
|
||||
baseline_cwd = (session.get("baseline_worktree_path") or "").strip()
|
||||
if not baseline_cwd:
|
||||
match = _BASELINE_CWD_RE.search(text)
|
||||
baseline_cwd = (match.group(1) if match else "").strip().rstrip(",.;")
|
||||
if not baseline_cwd or not _path_under_branches(baseline_cwd, project_root):
|
||||
reasons.append(
|
||||
"baseline validation claimed without baseline worktree cwd "
|
||||
"under branches/ (#398)"
|
||||
)
|
||||
baseline_sha = (session.get("baseline_target_sha") or "").strip()
|
||||
if not baseline_sha:
|
||||
match = _BASELINE_SHA_RE.search(text)
|
||||
baseline_sha = (match.group(1) if match else "").strip()
|
||||
if not baseline_sha:
|
||||
reasons.append(
|
||||
"baseline validation claimed without baseline target SHA (#398)"
|
||||
)
|
||||
baseline_cmd = (session.get("baseline_command") or "").strip()
|
||||
if not baseline_cmd:
|
||||
match = _BASELINE_CMD_RE.search(text)
|
||||
baseline_cmd = (match.group(1) if match else "").strip()
|
||||
if not baseline_cmd:
|
||||
reasons.append(
|
||||
"baseline validation claimed without exact baseline command (#398)"
|
||||
)
|
||||
|
||||
proven = not reasons and not violations
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": bool(violations) or not proven,
|
||||
"claims_validation": True,
|
||||
"expected_head_sha": expected_head or None,
|
||||
"observed_head_sha": observed_head or None,
|
||||
"working_directory": cwd or None,
|
||||
"reasons": reasons,
|
||||
"violations": violations,
|
||||
"safe_next_action": (
|
||||
"before validation record pwd, git rev-parse HEAD, git status, "
|
||||
"expected PR head SHA; run commands with git -C or cd in the same line"
|
||||
if not proven
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
+5
-11
@@ -38,21 +38,13 @@ fi
|
||||
branch="$1"
|
||||
start_ref="${2:-prgs/master}"
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
|
||||
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
|
||||
if [[ "$allow_unlinked" -eq 0 ]]; then
|
||||
locked_branch=$(python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '$repo_root')
|
||||
import issue_lock_store
|
||||
print(issue_lock_store.resolve_locked_branch_for_session('$branch'))
|
||||
")
|
||||
if [[ -z "$locked_branch" ]]; then
|
||||
echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2
|
||||
if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
|
||||
echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
|
||||
exit 2
|
||||
fi
|
||||
locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
|
||||
if [[ "$branch" != "$locked_branch" ]]; then
|
||||
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
|
||||
exit 2
|
||||
@@ -76,6 +68,8 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/.." && pwd)"
|
||||
worktree_name="${branch//\//-}"
|
||||
worktree_path="$repo_root/branches/$worktree_name"
|
||||
|
||||
|
||||
@@ -568,28 +568,6 @@ If the cause is unknown, do not erase the earlier failure with plain
|
||||
`gitea_validate_review_final_report` rejects reports that omit known earlier
|
||||
validation failures when `validation_session.observed_failures` is supplied.
|
||||
|
||||
## 21B. Validation status taxonomy (#406)
|
||||
|
||||
When the final report summarizes how validation concluded, use one of these
|
||||
**validation status** labels (distinct from per-command pass/fail entries):
|
||||
|
||||
* `passed` — raw PR-head validation passed on the unmodified head.
|
||||
* `failed` — raw PR-head validation failed and no allowed resolution path
|
||||
was proven.
|
||||
* `baseline-equivalent failure accepted` — only when a clean baseline
|
||||
worktree under `branches/` proves matching failure signatures on the target
|
||||
branch (baseline path, target SHA, exact commands, failure lists, and
|
||||
`failure signatures match: true`).
|
||||
* `raw-head failure resolved by merge simulation` — raw PR-head validation
|
||||
failed, but merge simulation into the current target passed cleanly; report
|
||||
merge simulation under `Worktree/index mutations` with full #317 proof.
|
||||
* `passed after transient failure investigation` — a later run passed after an
|
||||
earlier failure in the same session; document the failure history (#396).
|
||||
|
||||
Do not use `baseline-equivalent failure accepted` when only merge simulation
|
||||
resolved the failure. Do not use bare `passed` when raw PR-head validation
|
||||
failed unless one of the resolution statuses above applies.
|
||||
|
||||
## 22. Baseline validation rule
|
||||
|
||||
Do not run tests in the main checkout.
|
||||
@@ -626,28 +604,6 @@ Do not claim “full-suite failures are pre-existing” unless baseline proof is
|
||||
|
||||
## 23. Validation command proof rule
|
||||
|
||||
Before any diff, test, or compile validation, record in the same command
|
||||
transcript or final report:
|
||||
|
||||
* `pwd` or explicit working directory
|
||||
* `git rev-parse HEAD`
|
||||
* `git status --short --branch`
|
||||
* expected PR head SHA (candidate head SHA)
|
||||
|
||||
Validation commands must use one of:
|
||||
|
||||
* `git -C <review_worktree> ...`
|
||||
* `cd <review_worktree> && ...` in the same command
|
||||
* tool-provided explicit working-directory metadata
|
||||
|
||||
Do not rely on inferred shell cwd from a prior command in a different block.
|
||||
|
||||
`gitea_validate_review_final_report` rejects validation claims without
|
||||
cwd/HEAD proof when `validation_session` is supplied.
|
||||
|
||||
Baseline validation must document baseline worktree path, baseline target SHA,
|
||||
cwd proof, exact baseline command, and baseline result using the same rules.
|
||||
|
||||
Report the exact validation command as executed.
|
||||
|
||||
Report the working directory where validation ran.
|
||||
@@ -776,44 +732,6 @@ The final report must identify:
|
||||
* whether same-PR merge continuation was allowed
|
||||
* whether the run stopped as required
|
||||
|
||||
## 26B. Per-PR reviewer lease (#407)
|
||||
|
||||
Parallel reviewer sessions are allowed only when each session holds a distinct,
|
||||
live PR lease.
|
||||
|
||||
Before validation or review mutation on a selected PR:
|
||||
|
||||
1. Call `gitea_acquire_reviewer_pr_lease` with worktree path, candidate head SHA,
|
||||
and target branch SHA.
|
||||
2. Post heartbeats via `gitea_heartbeat_reviewer_pr_lease` before validation,
|
||||
after validation, before review mutation, and before merge.
|
||||
3. Do not approve, request changes, or merge unless the in-session lease
|
||||
matches the selected PR.
|
||||
|
||||
If PR head or target branch advances during the lease, stop and refresh
|
||||
inventory before continuing.
|
||||
|
||||
Final reports must include lease session id, acquisition proof, heartbeat
|
||||
status, and release/blocked status.
|
||||
|
||||
## 26C. Conflict-fix lease and stale-head protection (#399)
|
||||
|
||||
Before validating, approving, or merging a PR:
|
||||
|
||||
1. Check for an active conflict-fix lease on the PR; stop if one is active.
|
||||
2. Pin `expected_head_sha` before validation and pass it to
|
||||
`gitea_mark_final_review_decision`, `gitea_submit_pr_review`, and
|
||||
`gitea_merge_pr`.
|
||||
3. Re-fetch live PR head immediately before approval and merge; refuse when
|
||||
live head differs from the reviewed SHA.
|
||||
|
||||
Final reports must state:
|
||||
|
||||
* reviewed head SHA
|
||||
* final live head SHA before approval
|
||||
* final live head SHA before merge
|
||||
* whether any push occurred during validation
|
||||
|
||||
## 27. Merge rules
|
||||
|
||||
Before merge, rerun fresh live checks:
|
||||
@@ -824,7 +742,6 @@ Before merge, rerun fresh live checks:
|
||||
* author safety
|
||||
* PR re-fetch
|
||||
* reviewed head SHA unchanged
|
||||
* visible APPROVED review applies to the **current live PR head SHA** (`approval_at_current_head`); if the head moved after approval, re-review at the new head before merge (#471)
|
||||
* target branch freshly fetched
|
||||
* PR still open
|
||||
* PR still mergeable
|
||||
@@ -841,7 +758,6 @@ Do not merge if:
|
||||
* capability state is stale
|
||||
* worktree is dirty
|
||||
* PR head changed
|
||||
* approval is stale (approved SHA ≠ current live head SHA)
|
||||
* validation failed
|
||||
* inventory was incomplete
|
||||
* PR is already landed
|
||||
@@ -881,40 +797,6 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
|
||||
|
||||
If any local artifact is created after final cleanup, run and report a new final status check.
|
||||
|
||||
## 28A. Post-merge cleanup proof checklist (#402)
|
||||
|
||||
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
|
||||
|
||||
### Remote branch deletion checklist
|
||||
|
||||
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
|
||||
|
||||
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
|
||||
* Merge result: merged
|
||||
* Merge commit SHA: full 40-character SHA
|
||||
* Merged PR head branch / deleted branch: exact branch name (must match)
|
||||
* Branch protection: none / branch is not protected
|
||||
* Open PR inventory proof: no other open PR references the branch
|
||||
* Active heartbeat/claim/lease: none
|
||||
|
||||
### Local worktree removal checklist
|
||||
|
||||
When removing session-owned review/simulation worktrees under `branches/`, report:
|
||||
|
||||
* Session-owned worktree path: exact path under `branches/`
|
||||
* Pre-removal tracked state: clean
|
||||
* Pre-removal untracked state: clean
|
||||
* Git worktree list after removal: command output or equivalent proof
|
||||
|
||||
### Skipped cleanup
|
||||
|
||||
If any gate fails, report:
|
||||
|
||||
* Cleanup outcome: `CLEANUP_SKIPPED`
|
||||
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
|
||||
|
||||
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
|
||||
|
||||
## 29. Recovery handoff rules
|
||||
|
||||
If blocked, produce a recovery handoff with:
|
||||
@@ -1245,7 +1127,6 @@ Controller Handoff:
|
||||
* Files reviewed:
|
||||
* Validation:
|
||||
* Validation failure history:
|
||||
* Validation cwd/HEAD proof:
|
||||
* Official validation integrity status:
|
||||
* Terminal review mutation:
|
||||
* Review decision:
|
||||
|
||||
@@ -144,14 +144,6 @@ If the main checkout is dirty before selection, stop and produce a recovery hand
|
||||
|
||||
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
|
||||
|
||||
### Stacked PRs (explicit exception, #484)
|
||||
|
||||
Normal author work stays base-equivalent to `master`/`main`/`dev`. A **stacked PR** — deliberately based on another unmerged PR's branch — is the only sanctioned non-master base, and only when the operator/controller explicitly chooses it:
|
||||
|
||||
- Branch the `branches/` worktree from the dependency's branch, then lock with `gitea_lock_issue(..., stacked_base_branch=<dep-branch>, stacked_base_pr=<open-PR#>)`. The lock fails closed unless that open PR owns the branch; arbitrary or stale branches are rejected.
|
||||
- Open the PR with `gitea_create_pr(base=<dep-branch>)`. The body must state: `Stacked on PR #<X> / issue #<Y>`, `Base branch: <dep-branch>`, `Head branch: <this-branch>`, `Do not merge before PR #<X>`, and note retarget/rebase to `master` after the dependency lands if required.
|
||||
- This does not relax the main-checkout rule or bypass the issue lock — work still happens under `branches/`, and the approved base is recorded on the lock.
|
||||
|
||||
## 5. No raw MCP repair during normal issue work
|
||||
|
||||
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
|
||||
@@ -305,36 +297,6 @@ Report:
|
||||
|
||||
Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven.
|
||||
|
||||
### 10A. Duplicate-work gate phases (#400)
|
||||
|
||||
Before any file edits, prove duplicate-work clearance with
|
||||
`gitea_assess_work_issue_duplicate` or `gitea_lock_issue` (which runs the same
|
||||
gate). The gate checks live:
|
||||
|
||||
* open PRs linked to the issue (head branch or Closes/Fixes reference),
|
||||
* remote branches matching `issue-<number>`,
|
||||
* active claim leases from structured heartbeats.
|
||||
|
||||
Re-check immediately before:
|
||||
|
||||
* `gitea_commit_files` (commit),
|
||||
* branch push,
|
||||
* `gitea_create_pr` (PR creation).
|
||||
|
||||
If a concurrent open PR appears after work begins:
|
||||
|
||||
* before commit/push → stop and preserve local work without pushing,
|
||||
* after commit but before push → stop without pushing,
|
||||
* after push but before PR creation → stop and produce a reconciliation
|
||||
handoff instead of opening a PR.
|
||||
|
||||
Final reports must state exactly one duplicate-work outcome:
|
||||
|
||||
* `duplicate PR prevented`
|
||||
* `duplicate branch prevented`
|
||||
* `duplicate commit prevented`
|
||||
* `duplicate work not prevented`
|
||||
|
||||
## 11. Claim or lock the issue before implementation
|
||||
|
||||
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
|
||||
@@ -615,29 +577,6 @@ After push, report:
|
||||
|
||||
If push fails, stop and produce a recovery handoff.
|
||||
|
||||
## 20A. Conflict-fix lease and push gate (#399)
|
||||
|
||||
When pushing to an existing PR branch to resolve merge conflicts:
|
||||
|
||||
1. Call `gitea_acquire_conflict_fix_lease` before any push.
|
||||
2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with:
|
||||
* branch head before push
|
||||
* branch head after push (local)
|
||||
* session worktree path
|
||||
* push cwd
|
||||
* whether the push is fast-forward
|
||||
3. Do not push when a reviewer holds an active lease on the same PR.
|
||||
4. Do not force-push.
|
||||
5. Do not push from the main checkout or wrong cwd.
|
||||
|
||||
Conflict-fix final reports must state:
|
||||
|
||||
* branch head before push
|
||||
* branch head after push
|
||||
* active reviewer lease status
|
||||
* whether push was fast-forward
|
||||
* whether any reviewer was active
|
||||
|
||||
## 21. PR creation rules
|
||||
|
||||
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
|
||||
@@ -778,12 +717,6 @@ Use only precise categories:
|
||||
* External-state mutations:
|
||||
* Read-only diagnostics:
|
||||
|
||||
Issue-lock file (`/tmp/gitea_issue_lock.json`) read/write/delete is always an
|
||||
external-state mutation. Never claim `External-state mutations: none` after
|
||||
seeding, restoring, or removing that file. Manual lock seeding is not a normal
|
||||
recovery path (#447); use `gitea_lock_issue` or the #442 adoption recovery path
|
||||
instead. Link broader redesign: #438.
|
||||
|
||||
`git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics.
|
||||
|
||||
If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
"""Stacked-PR support for author issue locks and PR creation (#484).
|
||||
|
||||
Normal author work locks a worktree that is base-equivalent to ``master``/
|
||||
``main``/``dev`` and opens a PR against one of those base branches. A *stacked*
|
||||
PR is deliberately based on another unmerged PR's branch, so its worktree is not
|
||||
master-equivalent and its PR base is not a normal base branch.
|
||||
|
||||
This module holds the pure decision logic that lets:
|
||||
|
||||
* ``gitea_lock_issue`` approve a non-master base **only** when it is explicitly
|
||||
declared and proven to correspond to an open pull request, and
|
||||
* ``gitea_create_pr`` accept that approved base while still rejecting arbitrary,
|
||||
mismatched, or stale (merged/closed) branches.
|
||||
|
||||
The normal master-based path is unchanged: when no stacked base is declared, and
|
||||
when the PR base is a normal base branch, these helpers are inert. Nothing here
|
||||
bypasses the issue lock — a stacked base is recorded *on* the lock and re-checked
|
||||
at PR time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
# Phrases that satisfy the required merge-ordering statement in a stacked PR body.
|
||||
MERGE_ORDER_PHRASES = ("do not merge before", "do not merge until")
|
||||
|
||||
|
||||
def is_base_branch(base: str | None, base_branches: frozenset[str] | None = None) -> bool:
|
||||
"""True when ``base`` is a normal base branch (master/main/dev)."""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
return (base or "").strip() in bases
|
||||
|
||||
|
||||
def _pr_head_ref(pr: dict) -> str:
|
||||
head = pr.get("head") or {}
|
||||
if isinstance(head, dict):
|
||||
return (head.get("ref") or "").strip()
|
||||
return (str(head) if head else "").strip()
|
||||
|
||||
|
||||
def find_open_pr_for_branch(open_prs: list[dict] | None, branch: str | None) -> dict | None:
|
||||
"""Return the first OPEN PR whose head ref equals ``branch`` (else ``None``)."""
|
||||
branch = (branch or "").strip()
|
||||
if not branch:
|
||||
return None
|
||||
for pr in open_prs or []:
|
||||
if (pr.get("state") or "").strip().lower() != "open":
|
||||
continue
|
||||
if _pr_head_ref(pr) == branch:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def assess_stacked_base_declaration(
|
||||
*,
|
||||
stacked_base_branch: str | None,
|
||||
stacked_base_pr: int | None,
|
||||
open_prs: list[dict] | None,
|
||||
) -> dict:
|
||||
"""Validate an explicit stacked-base declaration at lock time.
|
||||
|
||||
Returns a dict with ``block`` (fail closed), ``reasons``, ``declared``
|
||||
(whether a stacked base was requested), and ``approved`` (the metadata to
|
||||
persist on the lock when valid, else ``None``).
|
||||
"""
|
||||
branch = (stacked_base_branch or "").strip()
|
||||
if not branch:
|
||||
# No stacked base requested — normal master-based lock path.
|
||||
return {"block": False, "reasons": [], "approved": None, "declared": False}
|
||||
|
||||
if branch in BASE_BRANCHES:
|
||||
return {
|
||||
"block": True,
|
||||
"declared": True,
|
||||
"approved": None,
|
||||
"reasons": [
|
||||
f"stacked base '{branch}' is already a normal base branch; do not "
|
||||
"declare a base branch as a stacked base"
|
||||
],
|
||||
}
|
||||
|
||||
if stacked_base_pr is None:
|
||||
return {
|
||||
"block": True,
|
||||
"declared": True,
|
||||
"approved": None,
|
||||
"reasons": [
|
||||
"stacked base branch declared without stacked_base_pr; a stacked PR "
|
||||
"must cite the open PR that owns the base branch"
|
||||
],
|
||||
}
|
||||
|
||||
pr = find_open_pr_for_branch(open_prs, branch)
|
||||
if pr is None:
|
||||
return {
|
||||
"block": True,
|
||||
"declared": True,
|
||||
"approved": None,
|
||||
"reasons": [
|
||||
f"stacked base branch '{branch}' does not correspond to any OPEN pull "
|
||||
"request; arbitrary or stale branches are not allowed as stacked bases"
|
||||
],
|
||||
}
|
||||
|
||||
if int(pr.get("number")) != int(stacked_base_pr):
|
||||
return {
|
||||
"block": True,
|
||||
"declared": True,
|
||||
"approved": None,
|
||||
"reasons": [
|
||||
f"declared stacked_base_pr #{stacked_base_pr} does not match the open "
|
||||
f"PR #{pr.get('number')} that owns base branch '{branch}'"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"block": False,
|
||||
"declared": True,
|
||||
"reasons": [],
|
||||
"approved": {
|
||||
"branch": branch,
|
||||
"pr_number": int(pr.get("number")),
|
||||
"verified_open": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def assess_stacked_pr_body(
|
||||
body: str | None, *, base_branch: str | None, pr_number: int | None
|
||||
) -> list[str]:
|
||||
"""Return the list of missing stacked-PR documentation fields (empty = ok)."""
|
||||
text = body or ""
|
||||
low = text.lower()
|
||||
missing: list[str] = []
|
||||
if base_branch and base_branch not in text:
|
||||
missing.append(f"base branch '{base_branch}'")
|
||||
if pr_number is not None and f"#{pr_number}" not in text:
|
||||
missing.append(f"stacked-on PR reference '#{pr_number}'")
|
||||
if not any(phrase in low for phrase in MERGE_ORDER_PHRASES):
|
||||
missing.append("merge-ordering statement (e.g. 'Do not merge before PR #<n>')")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_create_pr_base(
|
||||
*,
|
||||
base: str | None,
|
||||
approved_stacked_base: dict | None,
|
||||
body: str | None,
|
||||
open_prs: list[dict] | None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Validate the PR base at create time.
|
||||
|
||||
Normal base branches pass through unchanged (``stacked`` False). A non-base
|
||||
branch is allowed only when it matches the lock's approved stacked base, that
|
||||
base still has an open PR, and the body documents the stack.
|
||||
"""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
base = (base or "").strip()
|
||||
if base in bases:
|
||||
return {"block": False, "reasons": [], "stacked": False}
|
||||
|
||||
approved = approved_stacked_base or {}
|
||||
approved_branch = (approved.get("branch") or "").strip()
|
||||
if not approved_branch:
|
||||
return {
|
||||
"block": True,
|
||||
"stacked": True,
|
||||
"reasons": [
|
||||
f"PR base '{base}' is not one of {'/'.join(sorted(bases))} and the "
|
||||
"issue lock has no approved stacked base; re-lock with an explicit, "
|
||||
"proof-backed stacked base to open a stacked PR"
|
||||
],
|
||||
}
|
||||
|
||||
if base != approved_branch:
|
||||
return {
|
||||
"block": True,
|
||||
"stacked": True,
|
||||
"reasons": [
|
||||
f"PR base '{base}' does not match the issue lock's approved stacked "
|
||||
f"base '{approved_branch}'"
|
||||
],
|
||||
}
|
||||
|
||||
pr = find_open_pr_for_branch(open_prs, base)
|
||||
if pr is None:
|
||||
return {
|
||||
"block": True,
|
||||
"stacked": True,
|
||||
"reasons": [
|
||||
f"approved stacked base '{base}' no longer corresponds to an OPEN pull "
|
||||
"request (dependency merged, closed, or stale); retarget/rebase onto "
|
||||
"master or re-lock against a live base"
|
||||
],
|
||||
}
|
||||
|
||||
pr_number = approved.get("pr_number") or pr.get("number")
|
||||
missing = assess_stacked_pr_body(body, base_branch=base, pr_number=pr_number)
|
||||
if missing:
|
||||
return {
|
||||
"block": True,
|
||||
"stacked": True,
|
||||
"reasons": [
|
||||
"stacked PR body must document the stack; missing: "
|
||||
+ ", ".join(missing)
|
||||
],
|
||||
}
|
||||
|
||||
return {"block": False, "reasons": [], "stacked": True, "stacked_base_pr": pr_number}
|
||||
@@ -74,24 +74,18 @@ ISSUE_WRITE_ENV = {
|
||||
|
||||
class TestIssueLockArtifactWarning(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name}
|
||||
self._env_patcher = patch.dict(os.environ, env, clear=True)
|
||||
self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
|
||||
self._env_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._env_patcher.stop()
|
||||
self._lock_dir.cleanup()
|
||||
|
||||
@patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
@patch("mcp_server.api_get_all", return_value=[])
|
||||
@patch("mcp_server._auth", return_value="token x")
|
||||
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
|
||||
@patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
|
||||
@patch("issue_lock_worktree.read_worktree_git_state")
|
||||
def test_lock_success_includes_artifact_warning(self, mock_state, *_mocks):
|
||||
def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks):
|
||||
mock_state.return_value = {
|
||||
"current_branch": "master",
|
||||
"porcelain_status": "?? _emit_payload.py\n",
|
||||
|
||||
+7
-46
@@ -284,40 +284,8 @@ class TestSimpleToolAudit(_AuditWiringBase):
|
||||
self.assertEqual(result["number"], 9)
|
||||
|
||||
|
||||
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
|
||||
|
||||
class TestGatedToolAudit(_AuditWiringBase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from mcp_server import init_review_decision_lock
|
||||
from tests.test_mcp_server import _install_owned_reviewer_lease
|
||||
import reviewer_pr_lease
|
||||
|
||||
# init_review_decision_lock clears any prior session lease (#407).
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
self._lease_patch = _install_owned_reviewer_lease(8)
|
||||
self._lease_patch.start()
|
||||
self._auth_identity_patch = patch(
|
||||
"mcp_server._authenticated_username", return_value="reviewer-bot"
|
||||
)
|
||||
self._auth_identity_patch.start()
|
||||
self._pr_lease_comments_patch = patch(
|
||||
"mcp_server._list_pr_lease_comments", return_value=[]
|
||||
)
|
||||
self._pr_lease_comments_patch.start()
|
||||
self._pr_work_lease_patch = patch(
|
||||
"mcp_server._pr_work_lease_reviewer_block",
|
||||
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
|
||||
)
|
||||
self._pr_work_lease_patch.start()
|
||||
self.addCleanup(self._auth_identity_patch.stop)
|
||||
self.addCleanup(self._lease_patch.stop)
|
||||
self.addCleanup(self._pr_lease_comments_patch.stop)
|
||||
self.addCleanup(self._pr_work_lease_patch.stop)
|
||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
||||
|
||||
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
||||
return {"user": {"login": author}, "state": state,
|
||||
"head": {"sha": sha}, "mergeable": mergeable}
|
||||
@@ -330,8 +298,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
{"login": "merger-bot"}, self._pr("author-bot"),
|
||||
self._pr("author-bot"),
|
||||
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||
"commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
|
||||
"dismissed": False}],
|
||||
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
|
||||
{}, {"merged_commit_sha": "c1"},
|
||||
]
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
|
||||
@@ -367,9 +334,7 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_submit_review_success_audited(self, _auth, mock_api):
|
||||
# mark_final_review_decision and submit each run eligibility (user + PR).
|
||||
mock_api.side_effect = [
|
||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
||||
{"login": "reviewer-bot"}, self._pr("author-bot"),
|
||||
{"id": 7, "state": "APPROVED"},
|
||||
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
|
||||
@@ -378,16 +343,12 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from mcp_server import gitea_mark_final_review_decision
|
||||
|
||||
gitea_mark_final_review_decision(
|
||||
8, "approve", expected_head_sha="abc123", remote="prgs",
|
||||
)
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve",
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||
r = gitea_submit_pr_review(pr_number=8, action="approve",
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True)
|
||||
self.assertTrue(r["performed"])
|
||||
recs = self._records()
|
||||
self.assertEqual(len(recs), 1)
|
||||
|
||||
@@ -76,14 +76,7 @@ class CommitFilesCapabilityBase(unittest.TestCase):
|
||||
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(CONFIG))
|
||||
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
self._dup_fetcher_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._dup_fetcher_patcher.stop()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
|
||||
|
||||
@@ -66,19 +66,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
)
|
||||
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
|
||||
|
||||
import issue_lock_store
|
||||
import issue_lock_provenance
|
||||
|
||||
self._lock_dir = tempfile.TemporaryDirectory()
|
||||
os.environ["GITEA_ISSUE_LOCK_DIR"] = self._lock_dir.name
|
||||
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": 263,
|
||||
"branch": "feat/issue-263-native-commit-payloads",
|
||||
"claimant": {"username": "test-user", "profile": "test-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
self.lock_file_path = "/tmp/gitea_issue_lock.json"
|
||||
self.lock_data = {
|
||||
"issue_number": 263,
|
||||
"branch_name": "feat/issue-263-native-commit-payloads",
|
||||
@@ -86,13 +74,9 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
"org": "Example-Org",
|
||||
"repo": "Example-Repo",
|
||||
"worktree_path": self.locked_worktree_path,
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease.get("claimant"),
|
||||
),
|
||||
}
|
||||
self.lock_file_path = issue_lock_store.bind_session_lock(self.lock_data)
|
||||
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(self.lock_data))
|
||||
|
||||
# Reset preflight status to bypass/pass verification in tests
|
||||
self.orig_whoami_called = mcp_server._preflight_whoami_called
|
||||
@@ -100,14 +84,7 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
|
||||
self._dup_fetcher_patcher = patch(
|
||||
"mcp_server.issue_duplicate_context_fetcher",
|
||||
return_value=([], [], {"status": "not_claimed"}),
|
||||
)
|
||||
self._dup_fetcher_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self._dup_fetcher_patcher.stop()
|
||||
self._remotes.stop()
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
|
||||
@@ -116,7 +93,8 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
|
||||
self._dir.cleanup()
|
||||
self.locked_worktree_dir.cleanup()
|
||||
self._lock_dir.cleanup()
|
||||
if os.path.exists(self.lock_file_path):
|
||||
os.remove(self.lock_file_path)
|
||||
|
||||
def _env(self, profile: str) -> dict:
|
||||
return {
|
||||
@@ -125,7 +103,6 @@ class TestCommitPayloads(unittest.TestCase):
|
||||
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||
"GITEA_TEST_PORCELAIN": "",
|
||||
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
|
||||
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
|
||||
}
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
|
||||
@@ -106,32 +106,20 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase):
|
||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
|
||||
@patch("os.path.exists", return_value=True)
|
||||
@patch("os.path.isdir", return_value=True)
|
||||
@patch("author_mutation_worktree.subprocess.run")
|
||||
@patch("subprocess.run")
|
||||
def test_create_issue_wrong_repo_fails_closed(self, mock_run, mock_amw_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
||||
def test_create_issue_wrong_repo_fails_closed(self, mock_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
|
||||
# Mock subprocess.run for git --git-common-dir to return a different path
|
||||
mock_res = MagicMock()
|
||||
mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n"
|
||||
mock_run.return_value = mock_res
|
||||
|
||||
wrong_repo_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
|
||||
|
||||
def _subprocess_side_effect(cmd, *args, **kwargs):
|
||||
mock_res = MagicMock(returncode=0)
|
||||
if "--git-common-dir" in cmd:
|
||||
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
|
||||
if cwd == wrong_repo_path:
|
||||
mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n"
|
||||
else:
|
||||
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
|
||||
else:
|
||||
mock_res.stdout = ""
|
||||
return mock_res
|
||||
|
||||
mock_run.side_effect = _subprocess_side_effect
|
||||
mock_amw_run.side_effect = _subprocess_side_effect
|
||||
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(
|
||||
title="Test issue", body="body", worktree_path=wrong_repo_path
|
||||
)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(
|
||||
title="Test issue", body="body", worktree_path=wrong_repo_path
|
||||
)
|
||||
self.assertIn("does not belong to the target repository", str(ctx.exception))
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
|
||||
@@ -41,10 +41,6 @@ def _review_handoff(**overrides):
|
||||
"- Selected PR: #203",
|
||||
"- Reviewer eligibility: eligible",
|
||||
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Push occurred during validation: no",
|
||||
"- Worktree path: branches/review-203",
|
||||
"- Worktree dirty: clean",
|
||||
"- Scratch worktree used: yes (branches/review-203)",
|
||||
|
||||
@@ -1,227 +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,
|
||||
build_non_adoption_lock_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"])
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["matched_branch"], REQ)
|
||||
self.assertEqual(result["matched_head_sha"], "934688a")
|
||||
|
||||
def test_exact_own_branch_adopted_when_sha_missing(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420, requested_branch=REQ, existing_branches=[REQ]
|
||||
)
|
||||
self.assertEqual(result["outcome"], ADOPT)
|
||||
self.assertIsNone(result["matched_head_sha"])
|
||||
|
||||
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"])
|
||||
self.assertFalse(result["adopt"])
|
||||
self.assertIn("feat/issue-420-other-work", result["competing_branches"])
|
||||
self.assertIn("fail closed", result["reason"])
|
||||
|
||||
def test_own_branch_plus_competing_branch_blocks(self):
|
||||
# Ambiguous ownership: fail closed even though the exact branch exists.
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": REQ}, {"name": "feat/issue-420-rogue"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], BLOCK_COMPETING)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["competing_branches"], ["feat/issue-420-rogue"])
|
||||
|
||||
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)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertFalse(result["adopt"])
|
||||
|
||||
def test_empty_branch_list_is_normal_path(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=420, requested_branch=REQ, existing_branches=[]
|
||||
)
|
||||
self.assertEqual(result["outcome"], NO_MATCH)
|
||||
|
||||
def test_higher_issue_number_branch_does_not_block_lower_issue_adoption(self):
|
||||
# issue-420 must not be treated as competing work for issue #42.
|
||||
own_branch = "feat/issue-42-widget"
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=42,
|
||||
requested_branch=own_branch,
|
||||
existing_branches=[
|
||||
{"name": own_branch, "commit_sha": "abc1234"},
|
||||
{"name": "feat/issue-420-server-code-parity"},
|
||||
],
|
||||
)
|
||||
self.assertEqual(result["outcome"], ADOPT)
|
||||
self.assertTrue(result["adopt"])
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["matched_branch"], own_branch)
|
||||
|
||||
def test_unrelated_higher_number_branch_is_ignored_without_own_branch(self):
|
||||
result = assess_own_branch_adoption(
|
||||
issue_number=42,
|
||||
requested_branch="feat/issue-42-thing",
|
||||
existing_branches=[{"name": "feat/issue-420-server-code-parity"}],
|
||||
)
|
||||
self.assertEqual(result["outcome"], NO_MATCH)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertFalse(result["adopt"])
|
||||
|
||||
|
||||
class TestBuildAdoptionProof(unittest.TestCase):
|
||||
def test_proof_has_all_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",
|
||||
)
|
||||
for key in (
|
||||
"issue_number",
|
||||
"branch_name",
|
||||
"branch_head_commit",
|
||||
"adoption_reason",
|
||||
"no_existing_pr_proof",
|
||||
"no_competing_live_lock_proof",
|
||||
"lock_file_path",
|
||||
"lock_file_status",
|
||||
):
|
||||
self.assertIn(key, proof)
|
||||
self.assertEqual(proof["branch_head_commit"], "934688a")
|
||||
self.assertTrue(proof["no_existing_pr_proof"])
|
||||
self.assertTrue(proof["no_competing_live_lock_proof"])
|
||||
|
||||
|
||||
class TestExplicitAdoptionProofFields(unittest.TestCase):
|
||||
"""#477: explicit, citable adoption-proof fields for all outcomes."""
|
||||
|
||||
def _proof(self, assessment, branch):
|
||||
return build_adoption_proof(
|
||||
issue_number=420,
|
||||
branch_name=branch,
|
||||
assessment=assessment,
|
||||
open_pr_checked=True,
|
||||
competing_lock_checked=True,
|
||||
lock_file_path="/tmp/example-lock.json",
|
||||
lock_file_status="written",
|
||||
)
|
||||
|
||||
def test_adopt_proof_exposes_explicit_fields(self):
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
|
||||
)
|
||||
proof = self._proof(assessment, REQ)
|
||||
self.assertEqual(proof["adoption_decision"], "ADOPT")
|
||||
self.assertTrue(proof["adopted"])
|
||||
self.assertEqual(proof["adopted_branch"], REQ)
|
||||
self.assertEqual(proof["adopted_branch_head"], "934688a")
|
||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
||||
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
|
||||
self.assertIn("gitea_create_pr", proof["safe_next_action"])
|
||||
self.assertIn("exactly matches", proof["matcher_summary"])
|
||||
|
||||
def test_block_proof_reports_competing_and_does_not_claim_adoption(self):
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-420-rogue"}],
|
||||
)
|
||||
proof = self._proof(assessment, REQ)
|
||||
self.assertEqual(proof["adoption_decision"], "BLOCK_COMPETING")
|
||||
self.assertFalse(proof["adopted"])
|
||||
self.assertIsNone(proof["adopted_branch"])
|
||||
self.assertIsNone(proof["adopted_branch_head"])
|
||||
self.assertEqual(proof["competing_branch_check"]["result"], "blocked")
|
||||
self.assertIn(
|
||||
"feat/issue-420-rogue",
|
||||
proof["competing_branch_check"]["competing_branches"],
|
||||
)
|
||||
self.assertIn("fail closed", proof["safe_next_action"])
|
||||
|
||||
def test_no_match_proof_does_not_claim_adoption(self):
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=420,
|
||||
requested_branch=REQ,
|
||||
existing_branches=[{"name": "feat/issue-999-unrelated"}],
|
||||
)
|
||||
proof = self._proof(assessment, REQ)
|
||||
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
|
||||
self.assertFalse(proof["adopted"])
|
||||
self.assertIsNone(proof["adopted_branch"])
|
||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
||||
|
||||
def test_substring_collision_stays_boundary_safe(self):
|
||||
# issue-42 must not adopt/claim against an issue-420 branch (#440/#477).
|
||||
own = "feat/issue-42-widget"
|
||||
assessment = assess_own_branch_adoption(
|
||||
issue_number=42,
|
||||
requested_branch=own,
|
||||
existing_branches=[
|
||||
{"name": own, "commit_sha": "abc1234"},
|
||||
{"name": "feat/issue-420-server-code-parity"},
|
||||
],
|
||||
)
|
||||
proof = self._proof(assessment, own)
|
||||
self.assertEqual(proof["adoption_decision"], "ADOPT")
|
||||
self.assertEqual(proof["adopted_branch"], own)
|
||||
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
|
||||
|
||||
def test_non_adoption_lock_proof_is_adoption_free(self):
|
||||
proof = build_non_adoption_lock_proof(
|
||||
issue_number=196, branch_name="feat/issue-196-mutations"
|
||||
)
|
||||
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
|
||||
self.assertFalse(proof["adopted"])
|
||||
self.assertIsNone(proof["adopted_branch"])
|
||||
self.assertIsNone(proof["adopted_branch_head"])
|
||||
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
|
||||
self.assertIn("no adoption", proof["safe_next_action"].lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Tests for issue-lock provenance and external-state disclosure (#447)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import issue_lock_provenance as ilp # noqa: E402
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
|
||||
|
||||
def _sanctioned_lock(**overrides):
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": 447,
|
||||
"branch": "feat/issue-447-lock-provenance",
|
||||
"claimant": {"username": "jcwalker3", "profile": "prgs-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
data = {
|
||||
"issue_number": 447,
|
||||
"branch_name": "feat/issue-447-lock-provenance",
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": ilp.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease["claimant"],
|
||||
),
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
class TestLockProvenanceForCreatePr(unittest.TestCase):
|
||||
def test_sanctioned_lock_passes(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(_sanctioned_lock())
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_manual_seed_without_provenance_blocked(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(
|
||||
{"issue_number": 420, "branch_name": "feat/x", "work_lease": {}}
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("lock_provenance", result["reasons"][0])
|
||||
|
||||
def test_operator_override_requires_reason(self):
|
||||
result = ilp.assess_lock_file_for_create_pr(
|
||||
_sanctioned_lock(
|
||||
lock_provenance=ilp.build_sanctioned_lock_provenance(
|
||||
tool="operator_override",
|
||||
source=ilp.SOURCE_OPERATOR_OVERRIDE,
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestExternalStateReportRules(unittest.TestCase):
|
||||
def test_seed_with_external_none_blocked(self):
|
||||
report = (
|
||||
"Restored /tmp/gitea_issue_lock.json to unblock PR creation.\n"
|
||||
"- External-state mutations: none\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_seed_with_disclosure_passes(self):
|
||||
report = (
|
||||
"Restored /tmp/gitea_issue_lock.json after MCP restart.\n"
|
||||
"- External-state mutations: wrote /tmp/gitea_issue_lock.json\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_remove_claimed_as_cleanup_only_blocked(self):
|
||||
report = (
|
||||
"rm /tmp/gitea_issue_lock.json after PR creation.\n"
|
||||
"- Cleanup mutations: lock removed\n"
|
||||
"- External-state mutations: none\n"
|
||||
)
|
||||
result = ilp.assess_issue_lock_external_state_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_manual_lock_pr_without_override_blocked(self):
|
||||
report = (
|
||||
"Programmatically seeded gitea_issue_lock.json then gitea_create_pr.\n"
|
||||
"PR #444 created.\n"
|
||||
)
|
||||
result = ilp.assess_manual_lock_pr_without_override(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_author_reviewer_same_run_blocked(self):
|
||||
report = (
|
||||
"gitea_create_pr opened PR #444.\n"
|
||||
"Submitted approve review on PR #444.\n"
|
||||
)
|
||||
result = ilp.assess_author_reviewer_same_run_report(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
|
||||
class TestFinalReportValidatorIntegration(unittest.TestCase):
|
||||
def test_work_issue_blocks_hidden_lock_mutation(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Task: work issue #420\n"
|
||||
"- External-state mutations: none\n"
|
||||
"Restored /tmp/gitea_issue_lock.json before PR creation.\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, "work_issue")
|
||||
rule_ids = {f["rule_id"] for f in result["findings"]}
|
||||
self.assertIn("shared.issue_lock_external_state", rule_ids)
|
||||
self.assertTrue(result["blocked"])
|
||||
|
||||
def test_review_pr_blocks_create_and_approve(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Task: review PR #444\n"
|
||||
"- Review decision: approve\n"
|
||||
"Created PR #444 via gitea_create_pr earlier in this run.\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
rule_ids = {f["rule_id"] for f in result["findings"]}
|
||||
self.assertIn("shared.author_reviewer_same_run", rule_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,280 +0,0 @@
|
||||
"""Unit tests for keyed issue-lock storage (#443) and flock hardening (#438)."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
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_approved_stacked_base_survives_round_trip(self):
|
||||
# #484: the approved stacked base recorded on the lock must persist so
|
||||
# gitea_create_pr can validate the non-master base at PR time.
|
||||
record = _lock_record(
|
||||
issue_number=482,
|
||||
branch_name="feat/issue-482-skip-stale-request-changes-pr",
|
||||
approved_stacked_base={
|
||||
"branch": "feat/issue-478-mcp-menu-shell",
|
||||
"pr_number": 479,
|
||||
"verified_open": True,
|
||||
},
|
||||
)
|
||||
path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=482,
|
||||
)
|
||||
ils.save_lock_file(path, record)
|
||||
stored = ils.read_lock_file(path)
|
||||
self.assertEqual(stored["approved_stacked_base"]["branch"], "feat/issue-478-mcp-menu-shell")
|
||||
self.assertEqual(stored["approved_stacked_base"]["pr_number"], 479)
|
||||
self.assertTrue(stored["approved_stacked_base"]["verified_open"])
|
||||
|
||||
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)
|
||||
|
||||
def test_concurrent_bind_same_issue_only_one_wins(self):
|
||||
barrier = threading.Barrier(2)
|
||||
results: list[str | Exception] = []
|
||||
|
||||
def worker():
|
||||
barrier.wait()
|
||||
try:
|
||||
ils.bind_session_lock(
|
||||
_lock_record(worktree_path=f"/tmp/wt-{threading.get_ident()}")
|
||||
)
|
||||
results.append("ok")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
successes = [item for item in results if item == "ok"]
|
||||
failures = [item for item in results if isinstance(item, Exception)]
|
||||
self.assertEqual(len(successes), 1)
|
||||
self.assertEqual(len(failures), 1)
|
||||
failure_text = str(failures[0]).lower()
|
||||
self.assertTrue(
|
||||
"active" in failure_text or "lock contention" in failure_text,
|
||||
failures[0],
|
||||
)
|
||||
|
||||
def test_verify_lock_for_mutation_blocks_stale_lock(self):
|
||||
record = _lock_record(
|
||||
work_lease=_lease("2000-01-01T00:00:00Z"),
|
||||
)
|
||||
record["pid"] = 999999
|
||||
record["session_pid"] = 999999
|
||||
result = ils.verify_lock_for_mutation(
|
||||
record,
|
||||
issue_number=420,
|
||||
branch_name="feat/issue-420-server-code-parity",
|
||||
worktree_path="/tmp/wt-420",
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("not live", result["reasons"][0])
|
||||
|
||||
def test_list_live_locks_excludes_stale_records(self):
|
||||
live_path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=420,
|
||||
)
|
||||
ils.save_lock_file(
|
||||
live_path,
|
||||
_lock_record(worktree_path="/tmp/wt-420"),
|
||||
)
|
||||
stale_path = ils.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=440,
|
||||
)
|
||||
ils.save_lock_file(
|
||||
stale_path,
|
||||
_lock_record(
|
||||
issue_number=440,
|
||||
branch_name="feat/issue-440-recovery",
|
||||
work_lease=_lease("2000-01-01T00:00:00Z"),
|
||||
worktree_path="/tmp/wt-440",
|
||||
),
|
||||
)
|
||||
live = ils.list_live_locks(lock_dir=self.lock_dir)
|
||||
self.assertEqual([entry["issue_number"] for entry in live], [420])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -72,59 +72,6 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
|
||||
class TestStackedBaseEquivalence(unittest.TestCase):
|
||||
"""extra_bases (an approved stacked base) can anchor base-equivalence (#484)."""
|
||||
|
||||
def _git(self, *args):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(
|
||||
["git", "-C", self.repo, *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.repo = self.tmp.name
|
||||
subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True)
|
||||
self._git("config", "user.email", "t@t")
|
||||
self._git("config", "user.name", "t")
|
||||
self._git("commit", "--allow-empty", "-q", "-m", "base")
|
||||
# Rename the default branch away from master/main/dev so no *base* branch
|
||||
# exists at HEAD — otherwise HEAD would be base-equivalent for free.
|
||||
self._git("branch", "-m", "trunk")
|
||||
# Create a non-master "dependency" branch at the same commit, then a
|
||||
# feature branch off it — mirrors a stacked worktree.
|
||||
self._git("branch", "feat/issue-100-dep")
|
||||
self._git("checkout", "-q", "-b", "feat/issue-101-stacked")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_stacked_base_not_equivalent_without_extra_bases(self):
|
||||
state = issue_lock_worktree.read_worktree_git_state(self.repo)
|
||||
# HEAD does not match master/main/dev, so base-equivalence is False.
|
||||
self.assertFalse(state["base_equivalent"])
|
||||
|
||||
def test_stacked_base_equivalent_with_extra_bases(self):
|
||||
state = issue_lock_worktree.read_worktree_git_state(
|
||||
self.repo, extra_bases=("feat/issue-100-dep",)
|
||||
)
|
||||
self.assertTrue(state["base_equivalent"])
|
||||
self.assertEqual(state["base_branch"], "feat/issue-100-dep")
|
||||
|
||||
def test_unrelated_extra_base_does_not_anchor(self):
|
||||
state = issue_lock_worktree.read_worktree_git_state(
|
||||
self.repo, extra_bases=("feat/does-not-exist",)
|
||||
)
|
||||
self.assertFalse(state["base_equivalent"])
|
||||
|
||||
|
||||
class TestIssueLockWorktreeResolution(unittest.TestCase):
|
||||
def test_explicit_path_wins(self):
|
||||
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
"""Tests for early duplicate-work detection (#400)."""
|
||||
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_lock_provenance
|
||||
import issue_lock_store
|
||||
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.api_get_all", return_value=[])
|
||||
@patch("mcp_server.get_auth_header", return_value="token x")
|
||||
def test_lock_issue_uses_injected_fetcher(self, _auth, _api):
|
||||
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,
|
||||
},
|
||||
):
|
||||
with tempfile.TemporaryDirectory() as lock_dir:
|
||||
with patch.dict(os.environ, {
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
|
||||
"GITEA_ISSUE_LOCK_DIR": lock_dir,
|
||||
}, clear=True):
|
||||
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._env_patch = patch.dict(
|
||||
os.environ,
|
||||
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
|
||||
clear=False,
|
||||
)
|
||||
self._env_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"):
|
||||
worktree_path = os.path.realpath(os.getcwd())
|
||||
work_lease = {
|
||||
"operation_type": "author_issue_work",
|
||||
"issue_number": issue_number,
|
||||
"branch": branch,
|
||||
"claimant": {"username": "test-user", "profile": "test-author"},
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
}
|
||||
issue_lock_store.bind_session_lock({
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch,
|
||||
"remote": "prgs",
|
||||
"org": "Example-Org",
|
||||
"repo": "Example-Repo",
|
||||
"worktree_path": worktree_path,
|
||||
"work_lease": work_lease,
|
||||
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||
tool="gitea_lock_issue",
|
||||
claimant=work_lease.get("claimant"),
|
||||
),
|
||||
})
|
||||
|
||||
@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",
|
||||
worktree_path=os.path.realpath(os.getcwd()),
|
||||
)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIsNone(result.get("number"))
|
||||
self.assertIn("duplicate_gate", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -122,19 +122,15 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api, mock_get_all):
|
||||
from mcp_server import init_review_decision_lock
|
||||
from tests.test_mcp_server import FULL_HEAD_SHA, _seed_ready_review_decision
|
||||
|
||||
head_sha = FULL_HEAD_SHA
|
||||
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
||||
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
||||
mock_api.side_effect = [
|
||||
{"login": "jcwalker3"}, # /user (inventory)
|
||||
{"login": "jcwalker3"}, # /user (submit eligibility)
|
||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/9
|
||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
|
||||
]
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
||||
_seed_ready_review_decision(9, "approve", sha=head_sha, remote="prgs")
|
||||
gitea_mark_final_review_decision(9, "approve", remote="prgs")
|
||||
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_review_pr(
|
||||
|
||||
@@ -213,12 +213,6 @@ def test_validation_failure_history_verifier_exported():
|
||||
assert callable(assess_validation_failure_history_report)
|
||||
|
||||
|
||||
def test_validation_cwd_proof_verifier_exported():
|
||||
from review_proofs import assess_validation_cwd_proof_report
|
||||
|
||||
assert callable(assess_validation_cwd_proof_report)
|
||||
|
||||
|
||||
def test_prior_blocker_skip_verifier_exported():
|
||||
from review_proofs import assess_prior_blocker_skip_proof
|
||||
|
||||
|
||||
+158
-625
File diff suppressed because it is too large
Load Diff
@@ -1,59 +0,0 @@
|
||||
"""Hermetic tests for merge approval head pinning (#471)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from merge_approval_gate import assess_merge_approval_head # noqa: E402
|
||||
|
||||
HEAD_OLD = "8b61c4b41f1b49b271ed3b99657431cf06eeda3e"
|
||||
HEAD_NEW = "3e4b721d60e97147ba0704773cf57cd0d42cbe31"
|
||||
|
||||
|
||||
class TestMergeApprovalGate(unittest.TestCase):
|
||||
def test_fresh_approval_at_current_head(self):
|
||||
result = assess_merge_approval_head(
|
||||
current_head_sha=HEAD_NEW,
|
||||
latest_by_reviewer={
|
||||
"reviewer1": {
|
||||
"verdict": "APPROVED",
|
||||
"dismissed": False,
|
||||
"reviewed_head_sha": HEAD_NEW,
|
||||
"submitted_at": "2026-07-06T12:00:00Z",
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertTrue(result["approval_at_current_head"])
|
||||
self.assertIsNone(result["stale_approval_block_reason"])
|
||||
|
||||
def test_stale_approval_after_rebase(self):
|
||||
result = assess_merge_approval_head(
|
||||
current_head_sha=HEAD_NEW,
|
||||
latest_by_reviewer={
|
||||
"reviewer1": {
|
||||
"verdict": "APPROVED",
|
||||
"dismissed": False,
|
||||
"reviewed_head_sha": HEAD_OLD,
|
||||
"submitted_at": "2026-07-06T10:00:00Z",
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertFalse(result["approval_at_current_head"])
|
||||
self.assertEqual(result["latest_approved_head_sha"], HEAD_OLD)
|
||||
self.assertIn("stale approval", result["stale_approval_block_reason"])
|
||||
self.assertIn(HEAD_OLD, result["stale_approval_block_reason"])
|
||||
self.assertIn(HEAD_NEW, result["stale_approval_block_reason"])
|
||||
self.assertIn("re-review PR at current head", result["stale_approval_block_reason"])
|
||||
|
||||
def test_no_approval_entries(self):
|
||||
result = assess_merge_approval_head(
|
||||
current_head_sha=HEAD_NEW,
|
||||
latest_by_reviewer={},
|
||||
)
|
||||
self.assertFalse(result["approval_at_current_head"])
|
||||
self.assertIsNone(result["latest_approved_head_sha"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,240 +0,0 @@
|
||||
"""Tests for namespace-scoped MCP workspace binding (#510)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if REPO_ROOT.parent.name == "branches":
|
||||
CONTROL_ROOT = str(REPO_ROOT.parent.parent)
|
||||
else:
|
||||
CONTROL_ROOT = str(REPO_ROOT)
|
||||
|
||||
AUTHOR_DIRTY = f"{CONTROL_ROOT}/branches/mcp-author-worktree"
|
||||
MERGER_CLEAN = f"{CONTROL_ROOT}/branches/merge-pr487-submit"
|
||||
REVIEWER_CLEAN = f"{CONTROL_ROOT}/branches/review-pr487-submit"
|
||||
RECONCILER_CLEAN = f"{CONTROL_ROOT}/branches/reconcile-pr487"
|
||||
MCP_PROCESS_ROOT = CONTROL_ROOT
|
||||
|
||||
|
||||
class TestNamespaceWorkspaceModule(unittest.TestCase):
|
||||
def test_author_env_ignored_for_merger_namespace(self):
|
||||
workspace, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="merger",
|
||||
worktree_path=None,
|
||||
process_project_root=MCP_PROCESS_ROOT,
|
||||
env={
|
||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
||||
nwb.MERGER_WORKTREE_ENV: MERGER_CLEAN,
|
||||
},
|
||||
profile_name="gitea-merger",
|
||||
)
|
||||
self.assertEqual(workspace, os.path.realpath(MERGER_CLEAN))
|
||||
self.assertEqual(source, f"{nwb.MERGER_WORKTREE_ENV} environment variable")
|
||||
|
||||
def test_author_env_ignored_for_reviewer_namespace(self):
|
||||
workspace, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reviewer",
|
||||
worktree_path=None,
|
||||
process_project_root=MCP_PROCESS_ROOT,
|
||||
env={
|
||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
||||
nwb.REVIEWER_WORKTREE_ENV: REVIEWER_CLEAN,
|
||||
},
|
||||
)
|
||||
self.assertEqual(workspace, os.path.realpath(REVIEWER_CLEAN))
|
||||
self.assertEqual(source, f"{nwb.REVIEWER_WORKTREE_ENV} environment variable")
|
||||
|
||||
def test_author_env_ignored_for_reconciler_namespace(self):
|
||||
workspace, source = nwb.resolve_namespace_workspace(
|
||||
role_kind="reconciler",
|
||||
worktree_path=None,
|
||||
process_project_root=MCP_PROCESS_ROOT,
|
||||
env={
|
||||
nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY,
|
||||
nwb.RECONCILER_WORKTREE_ENV: RECONCILER_CLEAN,
|
||||
},
|
||||
)
|
||||
self.assertEqual(workspace, os.path.realpath(RECONCILER_CLEAN))
|
||||
self.assertEqual(source, f"{nwb.RECONCILER_WORKTREE_ENV} environment variable")
|
||||
|
||||
def test_merger_profile_maps_to_merger_namespace(self):
|
||||
role = nwb.normalize_role_kind("reviewer", profile_name="gitea-merger")
|
||||
self.assertEqual(role, "merger")
|
||||
|
||||
def test_error_message_includes_path_and_binding_source(self):
|
||||
msg = nwb.format_namespace_workspace_binding_error(
|
||||
role_kind="merger",
|
||||
workspace_path=AUTHOR_DIRTY,
|
||||
binding_source=f"{nwb.AUTHOR_WORKTREE_ENV} environment variable",
|
||||
dirty_files=["gitea_mcp_server.py"],
|
||||
ignored_bindings=[f"{nwb.AUTHOR_WORKTREE_ENV}={AUTHOR_DIRTY} (ignored for merger namespace)"],
|
||||
)
|
||||
self.assertIn(AUTHOR_DIRTY, msg)
|
||||
self.assertIn("via", msg.lower())
|
||||
self.assertIn(nwb.AUTHOR_WORKTREE_ENV, msg)
|
||||
self.assertIn("Do not clean or reset foreign role worktrees", msg)
|
||||
|
||||
|
||||
class TestNamespaceWorkspaceIntegration(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._saved = {
|
||||
"whoami_called": srv._preflight_whoami_called,
|
||||
"capability_called": srv._preflight_capability_called,
|
||||
"resolved_role": srv._preflight_resolved_role,
|
||||
"whoami_violation": srv._preflight_whoami_violation,
|
||||
"capability_violation": srv._preflight_capability_violation,
|
||||
"in_test": srv._preflight_in_test_mode,
|
||||
}
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False)
|
||||
self._env_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_whoami_called = self._saved["whoami_called"]
|
||||
srv._preflight_capability_called = self._saved["capability_called"]
|
||||
srv._preflight_resolved_role = self._saved["resolved_role"]
|
||||
srv._preflight_whoami_violation = self._saved["whoami_violation"]
|
||||
srv._preflight_capability_violation = self._saved["capability_violation"]
|
||||
srv._preflight_in_test_mode = self._saved["in_test"]
|
||||
self._env_patch.stop()
|
||||
for key in (
|
||||
nwb.AUTHOR_WORKTREE_ENV,
|
||||
nwb.MERGER_WORKTREE_ENV,
|
||||
nwb.REVIEWER_WORKTREE_ENV,
|
||||
nwb.RECONCILER_WORKTREE_ENV,
|
||||
nwb.ACTIVE_WORKTREE_ENV,
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def _merger_profile(self):
|
||||
return {
|
||||
"profile_name": "gitea-merger",
|
||||
"allowed_operations": ["gitea.pr.merge", "gitea.read"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
}
|
||||
|
||||
def _reviewer_profile(self):
|
||||
return {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": ["gitea.pr.approve", "gitea.pr.review", "gitea.read"],
|
||||
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
|
||||
}
|
||||
|
||||
def _reconciler_profile(self):
|
||||
return {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": ["gitea.pr.close", "gitea.read"],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.create",
|
||||
],
|
||||
}
|
||||
|
||||
def _author_profile(self):
|
||||
return {
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.read"],
|
||||
"forbidden_operations": ["gitea.pr.merge", "gitea.pr.approve"],
|
||||
}
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_dirty_author_worktree_does_not_block_merger_with_clean_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
||||
srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN)
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_dirty_author_worktree_does_not_block_reviewer_with_clean_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||
os.environ[nwb.REVIEWER_WORKTREE_ENV] = REVIEWER_CLEAN
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._reviewer_profile()):
|
||||
srv.verify_preflight_purity("prgs", worktree_path=REVIEWER_CLEAN)
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_dirty_author_worktree_does_not_block_reconciler_with_clean_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||
os.environ[nwb.RECONCILER_WORKTREE_ENV] = RECONCILER_CLEAN
|
||||
srv._preflight_resolved_role = "reconciler"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._reconciler_profile()):
|
||||
srv.verify_preflight_purity("prgs", worktree_path=RECONCILER_CLEAN)
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_dirty_active_task_workspace_still_blocks_mutations(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
dirty = " M namespace_workspace_binding.py\n"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
||||
with mock.patch("gitea_mcp_server._get_workspace_porcelain", return_value=dirty):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN)
|
||||
self.assertIn(MERGER_CLEAN, str(ctx.exception))
|
||||
self.assertIn("binding", str(ctx.exception).lower())
|
||||
|
||||
def test_root_workspace_mutation_still_blocked_for_author(self):
|
||||
srv._preflight_resolved_role = "author"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._author_profile()):
|
||||
with mock.patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={"current_branch": "master"},
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity("prgs")
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_pr487_style_merge_binds_clean_merger_workspace(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n")
|
||||
os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY
|
||||
srv._preflight_resolved_role = "reviewer"
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()):
|
||||
resolved = srv._verify_role_mutation_workspace("prgs")
|
||||
self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT))
|
||||
@@ -230,9 +230,7 @@ class TestEligibilityDenialReport(PermissionReportBase):
|
||||
return PR_PAYLOAD
|
||||
mock_api.side_effect = fake_api
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
from tests.test_mcp_server import _seed_ready_review_decision
|
||||
|
||||
_seed_ready_review_decision(42, "approve", remote="prgs")
|
||||
mcp_server.gitea_mark_final_review_decision(42, "approve", remote="prgs")
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_submit_pr_review(
|
||||
pr_number=42, action="approve", body="lgtm", remote="prgs",
|
||||
@@ -274,9 +272,7 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
|
||||
return PR_PAYLOAD
|
||||
mock_api.side_effect = fake_api
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
from tests.test_mcp_server import _seed_ready_review_decision
|
||||
|
||||
_seed_ready_review_decision(42, "comment", remote="prgs")
|
||||
mcp_server.gitea_mark_final_review_decision(42, "comment", remote="prgs")
|
||||
with patch.dict(os.environ, self._env("author-profile")):
|
||||
res = mcp_server.gitea_submit_pr_review(
|
||||
pr_number=42, action="comment", body="finding", remote="prgs",
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Tests for post-merge cleanup proof enforcement (#402)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from post_merge_cleanup_proof import ( # noqa: E402
|
||||
CLEANUP_SKIPPED,
|
||||
assess_post_merge_cleanup_proof,
|
||||
)
|
||||
|
||||
MERGE_SHA = "a" * 40
|
||||
HEAD_BRANCH = "feat/issue-274-branches-only-worktrees"
|
||||
WORKTREE = "branches/review-pr374-conflicts"
|
||||
|
||||
|
||||
def _full_remote_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Merge commit SHA": MERGE_SHA,
|
||||
"Cleanup status": "remote branch deleted",
|
||||
"Delete-branch capability resolved": "gitea.branch.delete allowed via delete_branch task",
|
||||
"Merged PR head branch": HEAD_BRANCH,
|
||||
"Deleted branch": HEAD_BRANCH,
|
||||
"Branch protection": "none",
|
||||
"Open PR inventory proof": "no other open PR references branch (inventory complete)",
|
||||
"Active heartbeat/claim/lease": "none",
|
||||
"Cleanup mutations": "gitea_delete_branch on remote head branch",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _full_worktree_cleanup_report(**overrides):
|
||||
fields = {
|
||||
"Task": "review PR #374",
|
||||
"Merge result": "merged",
|
||||
"Cleanup status": "local worktree removed",
|
||||
"Removed worktree path": WORKTREE,
|
||||
"Pre-removal tracked state": "clean",
|
||||
"Pre-removal untracked state": "clean",
|
||||
"Git worktree list after removal": "only main checkout listed",
|
||||
"Cleanup mutations": "git worktree remove on session-owned review worktree",
|
||||
}
|
||||
fields.update(overrides)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TestPostMergeCleanupProof(unittest.TestCase):
|
||||
def test_no_cleanup_claim_passes(self):
|
||||
report = "## Controller Handoff\n- Task: review PR #1\n- Merge result: merged"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_missing_capability_proof_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Delete-branch capability resolved": "delete succeeded"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("capability" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_unmerged_pr_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Merge result": "not merged"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("merge result" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_wrong_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Deleted branch": "feat/other-branch"}
|
||||
)
|
||||
report += "\nDeleted branch does not match merged PR head branch"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_protected_branch_blocked(self):
|
||||
report = _full_remote_cleanup_report(**{"Branch protection": "enabled"})
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_open_pr_reference_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Open PR inventory proof": "PR #999 still references branch"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_active_claim_blocked(self):
|
||||
report = _full_remote_cleanup_report(
|
||||
**{"Active heartbeat/claim/lease": "status:in-progress on linked issue"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_dirty_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Pre-removal tracked state": "dirty"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("tracked" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_foreign_worktree_blocked(self):
|
||||
report = _full_worktree_cleanup_report(
|
||||
**{"Removed worktree path": "/tmp/foreign-worktree"}
|
||||
)
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("branches/" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_cleanup_skipped_with_blocker_passes(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: gitea.branch.delete capability not resolved in active profile",
|
||||
])
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["outcome"], CLEANUP_SKIPPED)
|
||||
|
||||
def test_cleanup_skipped_without_blocker_blocked(self):
|
||||
report = "## Controller Handoff\n- Cleanup outcome: CLEANUP_SKIPPED"
|
||||
result = assess_post_merge_cleanup_proof(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_full_remote_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_remote_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["remote_delete_claimed"])
|
||||
|
||||
def test_full_worktree_cleanup_passes(self):
|
||||
result = assess_post_merge_cleanup_proof(_full_worktree_cleanup_report())
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["worktree_remove_claimed"])
|
||||
|
||||
def test_validator_integration_blocks_unproven_delete(self):
|
||||
report = (
|
||||
"## Controller Handoff\n"
|
||||
"- Cleanup mutations: gitea_delete_branch deleted remote branch\n"
|
||||
)
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
self.assertTrue(result["blocked"])
|
||||
rule_ids = [f["rule_id"] for f in result["findings"]]
|
||||
self.assertIn("reviewer.post_merge_cleanup_proof", rule_ids)
|
||||
|
||||
def test_validator_integration_allows_skipped(self):
|
||||
report = "\n".join([
|
||||
"## Controller Handoff",
|
||||
"- Cleanup outcome: CLEANUP_SKIPPED",
|
||||
"- Cleanup blocker: branch still referenced by open PR #414",
|
||||
])
|
||||
result = assess_final_report_validator(report, task_kind="review_pr")
|
||||
cleanup_findings = [
|
||||
f for f in result["findings"]
|
||||
if f["rule_id"] == "reviewer.post_merge_cleanup_proof"
|
||||
]
|
||||
self.assertEqual(cleanup_findings, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
from mcp_server import ( # noqa: E402
|
||||
_list_pr_lease_comments,
|
||||
gitea_list_issue_comments,
|
||||
)
|
||||
|
||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||
AUTHOR_ENV = {
|
||||
"GITEA_PROFILE_NAME": "gitea-author",
|
||||
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
|
||||
}
|
||||
|
||||
|
||||
class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api):
|
||||
mock_api.return_value = {"message": "Unauthorized"}
|
||||
result = _list_pr_lease_comments(
|
||||
12, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api):
|
||||
mock_api.return_value = None
|
||||
result = _list_pr_lease_comments(
|
||||
12, remote="prgs", host=None, org=None, repo=None)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api):
|
||||
comment = {"id": 7, "body": "<!-- mcp-review-lease:v1 -->"}
|
||||
mock_api.return_value = [comment]
|
||||
result = _list_pr_lease_comments(
|
||||
12, remote="prgs", host=None, org=None, repo=None, limit=5)
|
||||
self.assertEqual(result, [comment])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_issue_comments_non_list_payload_returns_empty(self, _auth, mock_api):
|
||||
mock_api.return_value = {"message": "Unauthorized"}
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["comments"], [])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_list_issue_comments_list_payload_unchanged(self, _auth, mock_api):
|
||||
mock_api.return_value = [
|
||||
{
|
||||
"id": 101,
|
||||
"user": {"login": "alice"},
|
||||
"body": "hello",
|
||||
"created_at": "2026-07-03T00:00:00Z",
|
||||
"updated_at": "2026-07-03T01:00:00Z",
|
||||
}
|
||||
]
|
||||
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
|
||||
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(len(result["comments"]), 1)
|
||||
self.assertEqual(result["comments"][0]["author"], "alice")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -128,29 +128,18 @@ class TestPRQueueInventory(unittest.TestCase):
|
||||
"forbidden_operations": [],
|
||||
"base_url": None,
|
||||
}
|
||||
|
||||
from mcp_server import init_review_decision_lock
|
||||
from tests.test_mcp_server import (
|
||||
FULL_HEAD_SHA,
|
||||
_install_owned_reviewer_lease,
|
||||
_seed_ready_review_decision,
|
||||
)
|
||||
import reviewer_pr_lease
|
||||
|
||||
head_sha = FULL_HEAD_SHA
|
||||
mock_fetch.return_value = _final_page_fetch([
|
||||
{"number": 1, "title": "PR 1", "state": "open",
|
||||
"head": {"ref": "branch1", "sha": head_sha},
|
||||
"base": {"ref": "master"}, "mergeable": True,
|
||||
"user": {"login": "other_user"}}
|
||||
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}}
|
||||
])
|
||||
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
|
||||
# POST review (#244: state + visible-verdict GET reviews).
|
||||
mock_api.side_effect = [
|
||||
{"login": "reviewer1"},
|
||||
{"login": "reviewer1"},
|
||||
{
|
||||
"user": {"login": "other_user"},
|
||||
"state": "open",
|
||||
"head": {"sha": head_sha},
|
||||
"head": {"sha": "abc1"},
|
||||
"mergeable": True,
|
||||
},
|
||||
{"id": 100, "state": "APPROVED"},
|
||||
@@ -159,27 +148,17 @@ class TestPRQueueInventory(unittest.TestCase):
|
||||
"id": 100,
|
||||
"user": {"login": "reviewer1"},
|
||||
"state": "APPROVED",
|
||||
"commit_id": head_sha,
|
||||
"commit_id": "abc1",
|
||||
"submitted_at": "2026-07-06T10:00:00Z",
|
||||
"dismissed": False,
|
||||
}
|
||||
],
|
||||
]
|
||||
|
||||
with patch("mcp_server._authenticated_username", return_value="reviewer1"), \
|
||||
patch("mcp_server._list_pr_lease_comments", return_value=[]):
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
_seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs")
|
||||
lease_patch = _install_owned_reviewer_lease(
|
||||
1, head_sha=head_sha, session_id="inventory-review-lease",
|
||||
)
|
||||
lease_patch.start()
|
||||
self.addCleanup(lease_patch.stop)
|
||||
self.addCleanup(reviewer_pr_lease.clear_session_lease)
|
||||
result = gitea_review_pr(
|
||||
pr_number=1, event="APPROVE", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
gitea_mark_final_review_decision(1, "approve", remote="prgs")
|
||||
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("=== PR Queue Inventory ===", result["message"])
|
||||
self.assertIn("Repository:", result["message"])
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for conflict-fix and reviewer PR work leases (#399)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from pr_work_lease import ( # noqa: E402
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
REVIEWER_LEASE_MARKER,
|
||||
assess_conflict_fix_final_report,
|
||||
assess_conflict_fix_push,
|
||||
assess_head_sha_equality,
|
||||
assess_reviewer_mutation_blocked,
|
||||
assess_reviewer_stale_head_final_report,
|
||||
format_conflict_fix_lease_body,
|
||||
parse_conflict_fix_lease_comment,
|
||||
parse_reviewer_lease_comment,
|
||||
)
|
||||
|
||||
HEAD_A = "a" * 40
|
||||
HEAD_B = "b" * 40
|
||||
NOW = datetime(2026, 7, 7, 15, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _reviewer_lease_body(*, phase: str = "validating", expires_minutes: int = 60) -> str:
|
||||
expires = (NOW + timedelta(minutes=expires_minutes)).isoformat().replace("+00:00", "Z")
|
||||
return "\n".join([
|
||||
REVIEWER_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
"phase: " + phase,
|
||||
f"candidate_head: {HEAD_A}",
|
||||
f"expires_at: {expires}",
|
||||
"profile: prgs-reviewer",
|
||||
])
|
||||
|
||||
|
||||
def _conflict_fix_body(*, phase: str = "claimed", worktree: str = "branches/fix-376") -> str:
|
||||
expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z")
|
||||
return "\n".join([
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
"pr: #376",
|
||||
f"phase: {phase}",
|
||||
f"worktree: {worktree}",
|
||||
f"head_before: {HEAD_A}",
|
||||
f"expires_at: {expires}",
|
||||
"profile: prgs-author",
|
||||
])
|
||||
|
||||
|
||||
class TestLeaseParsing(unittest.TestCase):
|
||||
def test_parse_reviewer_lease(self):
|
||||
parsed = parse_reviewer_lease_comment(_reviewer_lease_body())
|
||||
self.assertEqual(parsed["pr_number"], 376)
|
||||
self.assertEqual(parsed["phase"], "validating")
|
||||
self.assertEqual(parsed["candidate_head"], HEAD_A)
|
||||
|
||||
def test_parse_conflict_fix_lease(self):
|
||||
parsed = parse_conflict_fix_lease_comment(_conflict_fix_body())
|
||||
self.assertEqual(parsed["pr_number"], 376)
|
||||
self.assertEqual(parsed["phase"], "claimed")
|
||||
|
||||
|
||||
class TestConflictFixPushGate(unittest.TestCase):
|
||||
def test_blocks_push_during_active_reviewer_lease(self):
|
||||
comments = [{"body": _reviewer_lease_body()}]
|
||||
result = assess_conflict_fix_push(
|
||||
pr_number=376,
|
||||
comments=comments,
|
||||
branch_head_before=HEAD_A,
|
||||
branch_head_after=HEAD_B,
|
||||
worktree_path="branches/fix-376",
|
||||
push_cwd="/proj/branches/fix-376",
|
||||
is_fast_forward=True,
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(any("reviewer lease" in r for r in result["reasons"]))
|
||||
|
||||
def test_rejects_non_fast_forward(self):
|
||||
result = assess_conflict_fix_push(
|
||||
pr_number=376,
|
||||
comments=[],
|
||||
branch_head_before=HEAD_A,
|
||||
branch_head_after=HEAD_B,
|
||||
worktree_path="branches/fix-376",
|
||||
push_cwd="/proj/branches/fix-376",
|
||||
is_fast_forward=False,
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(any("non-fast-forward" in r for r in result["reasons"]))
|
||||
|
||||
def test_wrong_cwd_push_attempt(self):
|
||||
result = assess_conflict_fix_push(
|
||||
pr_number=376,
|
||||
comments=[],
|
||||
branch_head_before=HEAD_A,
|
||||
branch_head_after=HEAD_B,
|
||||
worktree_path="branches/fix-376",
|
||||
push_cwd="/proj/master",
|
||||
is_fast_forward=True,
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(any("cwd" in r.lower() for r in result["reasons"]))
|
||||
|
||||
def test_sibling_conflict_fix_collision(self):
|
||||
comments = [{"body": _conflict_fix_body(phase="pushing", worktree="branches/other")}]
|
||||
result = assess_conflict_fix_push(
|
||||
pr_number=376,
|
||||
comments=comments,
|
||||
branch_head_before=HEAD_A,
|
||||
branch_head_after=HEAD_B,
|
||||
worktree_path="branches/fix-376",
|
||||
push_cwd="/proj/branches/fix-376",
|
||||
is_fast_forward=True,
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["push_allowed"])
|
||||
self.assertTrue(any("sibling conflict-fix" in r for r in result["reasons"]))
|
||||
|
||||
|
||||
class TestReviewerMutationGate(unittest.TestCase):
|
||||
def test_blocks_review_during_conflict_fix(self):
|
||||
comments = [{"body": _conflict_fix_body(phase="pushing")}]
|
||||
result = assess_reviewer_mutation_blocked(
|
||||
pr_number=376,
|
||||
comments=comments,
|
||||
reviewed_head_sha=HEAD_A,
|
||||
live_head_sha=HEAD_A,
|
||||
mutation="approve",
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(any("conflict-fix lease" in r for r in result["reasons"]))
|
||||
|
||||
def test_stale_head_blocks_approval(self):
|
||||
result = assess_reviewer_mutation_blocked(
|
||||
pr_number=376,
|
||||
comments=[],
|
||||
reviewed_head_sha=HEAD_A,
|
||||
live_head_sha=HEAD_B,
|
||||
mutation="merge",
|
||||
now=NOW,
|
||||
)
|
||||
self.assertFalse(result["mutation_allowed"])
|
||||
self.assertTrue(result["head_check"]["head_changed"])
|
||||
|
||||
def test_head_equality_required_fields(self):
|
||||
result = assess_head_sha_equality(HEAD_A, HEAD_B)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["head_changed"])
|
||||
|
||||
|
||||
class TestFinalReportProof(unittest.TestCase):
|
||||
def test_reviewer_stale_head_report_requires_fields(self):
|
||||
result = assess_reviewer_stale_head_final_report("no head proof here")
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_reviewer_stale_head_report_passes(self):
|
||||
report = "\n".join([
|
||||
f"Reviewed head SHA: {HEAD_A}",
|
||||
f"Final live head SHA before approval: {HEAD_A}",
|
||||
f"Final live head SHA before merge: {HEAD_A}",
|
||||
"Push occurred during validation: no",
|
||||
])
|
||||
result = assess_reviewer_stale_head_final_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_conflict_fix_report_requires_fields(self):
|
||||
result = assess_conflict_fix_final_report("incomplete")
|
||||
self.assertFalse(result["proven"])
|
||||
|
||||
def test_conflict_fix_report_passes(self):
|
||||
report = "\n".join([
|
||||
f"Branch head before push: {HEAD_A}",
|
||||
f"Branch head after push: {HEAD_B}",
|
||||
"Active reviewer lease status: none",
|
||||
"Whether push was fast-forward: yes",
|
||||
"Whether any reviewer was active: no",
|
||||
])
|
||||
result = assess_conflict_fix_final_report(report)
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
|
||||
class TestFormatLease(unittest.TestCase):
|
||||
def test_format_conflict_fix_lease_includes_marker(self):
|
||||
body = format_conflict_fix_lease_body(
|
||||
pr_number=376,
|
||||
branch="feat/x",
|
||||
worktree="branches/fix-376",
|
||||
profile="prgs-author",
|
||||
head_before=HEAD_A,
|
||||
)
|
||||
self.assertIn(CONFLICT_FIX_LEASE_MARKER, body)
|
||||
parsed = parse_conflict_fix_lease_comment(body)
|
||||
self.assertEqual(parsed["pr_number"], 376)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,106 +0,0 @@
|
||||
"""#469: capability preflight survives interleaved read-only whoami calls."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
|
||||
|
||||
class TestPreflightReadSurvival(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.orig_whoami = mcp_server._preflight_whoami_called
|
||||
self.orig_capability = mcp_server._preflight_capability_called
|
||||
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
|
||||
self.orig_capability_violation = mcp_server._preflight_capability_violation
|
||||
self.orig_resolved_role = mcp_server._preflight_resolved_role
|
||||
self.orig_resolved_task = mcp_server._preflight_resolved_task
|
||||
self.orig_process_start = mcp_server._process_start_porcelain
|
||||
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
|
||||
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
|
||||
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
os.environ["GITEA_TEST_PORCELAIN"] = ""
|
||||
mcp_server._preflight_whoami_called = False
|
||||
mcp_server._preflight_capability_called = False
|
||||
mcp_server._preflight_whoami_violation = False
|
||||
mcp_server._preflight_capability_violation = False
|
||||
mcp_server._preflight_resolved_role = None
|
||||
mcp_server._preflight_resolved_task = None
|
||||
mcp_server._process_start_porcelain = ""
|
||||
mcp_server._preflight_whoami_baseline_porcelain = None
|
||||
mcp_server._preflight_capability_baseline_porcelain = None
|
||||
|
||||
def tearDown(self):
|
||||
mcp_server._preflight_whoami_called = self.orig_whoami
|
||||
mcp_server._preflight_capability_called = self.orig_capability
|
||||
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
|
||||
mcp_server._preflight_capability_violation = self.orig_capability_violation
|
||||
mcp_server._preflight_resolved_role = self.orig_resolved_role
|
||||
mcp_server._preflight_resolved_task = self.orig_resolved_task
|
||||
mcp_server._process_start_porcelain = self.orig_process_start
|
||||
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
|
||||
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
|
||||
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
def test_interleaved_whoami_preserves_capability(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reconciler", resolved_task="close_pr"
|
||||
)
|
||||
self.assertTrue(mcp_server._preflight_capability_called)
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
|
||||
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
self.assertTrue(mcp_server._preflight_capability_called)
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
|
||||
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
self.assertFalse(mcp_server._preflight_capability_called)
|
||||
|
||||
def test_missing_capability_still_fails_closed(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
self.assertIn("has not been resolved", str(ctx.exception))
|
||||
|
||||
def test_task_mismatch_fails_closed(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="author", resolved_task="create_issue"
|
||||
)
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="close_pr")
|
||||
self.assertIn("task mismatch", str(ctx.exception))
|
||||
|
||||
def test_capability_consumed_after_mutation_gate(self):
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="author", resolved_task="create_issue"
|
||||
)
|
||||
mcp_server.verify_preflight_purity(task="create_issue")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
mcp_server.verify_preflight_purity(task="create_issue")
|
||||
self.assertIn("has not been resolved", str(ctx.exception))
|
||||
|
||||
def test_whoami_recovery_after_violation_clears_capability(self):
|
||||
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
self.assertTrue(mcp_server._preflight_whoami_violation)
|
||||
|
||||
del os.environ["GITEA_TEST_FORCE_DIRTY"]
|
||||
os.environ["GITEA_TEST_PORCELAIN"] = ""
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
self.assertFalse(mcp_server._preflight_whoami_violation)
|
||||
self.assertFalse(mcp_server._preflight_capability_called)
|
||||
|
||||
mcp_server.record_preflight_check(
|
||||
"capability", resolved_role="reviewer", resolved_task="review_pr"
|
||||
)
|
||||
mcp_server.verify_preflight_purity(task="review_pr")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Reconciler close_pr must not require author branches/ worktree (#468)."""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as srv
|
||||
|
||||
FAKE_AUTH = "token test"
|
||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
|
||||
RECONCILER_PROFILE = {
|
||||
"profile_name": "prgs-reconciler",
|
||||
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
],
|
||||
"audit_label": "prgs-reconciler",
|
||||
}
|
||||
|
||||
|
||||
class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_whoami_violation = False
|
||||
srv._preflight_capability_violation = False
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
def test_reconciler_close_pr_from_control_checkout_succeeds(
|
||||
self, mock_api, _profile, _ns, _auth
|
||||
):
|
||||
srv._preflight_resolved_role = "reconciler"
|
||||
mock_api.return_value = {
|
||||
"number": 414,
|
||||
"title": "old",
|
||||
"body": "",
|
||||
"state": "closed",
|
||||
"html_url": "https://gitea.example.com/pulls/414",
|
||||
}
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||
result = srv.gitea_edit_pr(414, state="closed", remote="prgs")
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["state"], "closed")
|
||||
mock_api.assert_called_once()
|
||||
|
||||
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
|
||||
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
|
||||
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
|
||||
@patch(
|
||||
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
|
||||
return_value=(True, []),
|
||||
)
|
||||
@patch("gitea_mcp_server.api_get_all", return_value=[])
|
||||
@patch(
|
||||
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
|
||||
return_value={"current_branch": "master"},
|
||||
)
|
||||
def test_author_create_issue_still_blocked_on_control_checkout(
|
||||
self, _git, _get_all, _role, _ns, _prof, _auth
|
||||
):
|
||||
srv._preflight_resolved_role = "author"
|
||||
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.gitea_create_issue(title="Test", body="body")
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -115,22 +115,6 @@ class TestPRReviewFeedbackDiscovery(unittest.TestCase):
|
||||
result["latest_review_state_by_reviewer"], {"reviewer1": "APPROVED"})
|
||||
self.assertFalse(result["has_blocking_change_requests"])
|
||||
self.assertTrue(result["approval_visible"])
|
||||
self.assertTrue(result["approval_at_current_head"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("mcp_server.get_profile")
|
||||
def test_stale_approval_not_at_current_head(self, mock_get_profile, _auth, mock_api):
|
||||
mock_get_profile.return_value = self._profile()
|
||||
mock_api.side_effect = [
|
||||
_pr_details(head_sha="newhead3"),
|
||||
[_review("reviewer1", "APPROVED", commit_id="oldhead1")],
|
||||
]
|
||||
result = gitea_get_pr_review_feedback(pr_number=5, remote="prgs")
|
||||
self.assertTrue(result["approval_visible"])
|
||||
self.assertFalse(result["approval_at_current_head"])
|
||||
self.assertEqual(result["latest_approved_head_sha"], "oldhead1")
|
||||
self.assertIn("stale approval", result["stale_approval_block_reason"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
|
||||
@@ -19,11 +19,7 @@ def _minimal_review_report(**overrides):
|
||||
"- Issue/PR: #182 / PR #203",
|
||||
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Files changed: review_proofs.py",
|
||||
"- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
|
||||
"- Push occurred during validation: no",
|
||||
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Mutations: review only",
|
||||
"- File edits by reviewer: none",
|
||||
"- Worktree/index mutations: none",
|
||||
@@ -84,7 +80,7 @@ class TestReviewFinalReportSchema(unittest.TestCase):
|
||||
|
||||
def test_reviewed_head_without_validation_blocks(self):
|
||||
report = _minimal_review_report().replace(
|
||||
"- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
|
||||
"- Validation: not run",
|
||||
)
|
||||
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||
|
||||
@@ -2346,7 +2346,6 @@ class TestWorkIssueFinalReport(unittest.TestCase):
|
||||
"- Safe next action: open PR",
|
||||
"- Next: open PR",
|
||||
"- Safety statement: no review/merge",
|
||||
"- Duplicate work outcome: duplicate work not prevented",
|
||||
])
|
||||
|
||||
def test_complete_work_issue_report_earns_a(self):
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Tests for per-PR reviewer leases (#407)."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import reviewer_pr_lease as leases
|
||||
|
||||
|
||||
def _lease_comment(
|
||||
pr_number: int,
|
||||
session_id: str,
|
||||
*,
|
||||
phase: str = "claimed",
|
||||
minutes_ago: int = 0,
|
||||
candidate_head: str = "a" * 40,
|
||||
) -> dict:
|
||||
now = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
||||
body = leases.format_lease_body(
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
pr_number=pr_number,
|
||||
issue_number=295,
|
||||
reviewer_identity="rev1",
|
||||
profile="prgs-reviewer",
|
||||
session_id=session_id,
|
||||
worktree="branches/review-pr382",
|
||||
phase=phase,
|
||||
candidate_head=candidate_head,
|
||||
target_branch="master",
|
||||
target_branch_sha="b" * 40,
|
||||
last_activity=now,
|
||||
)
|
||||
return {"id": 1, "body": body, "user": {"login": "rev1"}}
|
||||
|
||||
|
||||
class TestReviewerLeaseAcquire(unittest.TestCase):
|
||||
def setUp(self):
|
||||
leases.clear_session_lease()
|
||||
|
||||
def test_two_reviewers_cannot_lease_same_pr(self):
|
||||
comments = [_lease_comment(382, "session-a")]
|
||||
result = leases.assess_acquire_lease(
|
||||
comments,
|
||||
pr_number=382,
|
||||
reviewer_identity="rev2",
|
||||
profile="prgs-reviewer",
|
||||
session_id="session-b",
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
issue_number=295,
|
||||
worktree="branches/review-pr382-b",
|
||||
candidate_head="c" * 40,
|
||||
target_branch="master",
|
||||
target_branch_sha="d" * 40,
|
||||
)
|
||||
self.assertFalse(result["acquire_allowed"])
|
||||
self.assertTrue(any("already has active" in r for r in result["reasons"]))
|
||||
|
||||
def test_two_reviewers_can_lease_different_prs(self):
|
||||
comments = [_lease_comment(382, "session-a")]
|
||||
result = leases.assess_acquire_lease(
|
||||
comments,
|
||||
pr_number=383,
|
||||
reviewer_identity="rev2",
|
||||
profile="prgs-reviewer",
|
||||
session_id="session-b",
|
||||
repo="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
issue_number=296,
|
||||
worktree="branches/review-pr383",
|
||||
candidate_head="c" * 40,
|
||||
target_branch="master",
|
||||
target_branch_sha="d" * 40,
|
||||
)
|
||||
self.assertTrue(result["acquire_allowed"])
|
||||
self.assertIsNotNone(result["lease_body"])
|
||||
|
||||
|
||||
class TestReviewerLeaseFreshness(unittest.TestCase):
|
||||
def test_stale_warning_after_30_minutes(self):
|
||||
lease = leases.parse_lease_comment(
|
||||
_lease_comment(382, "session-a", minutes_ago=35)["body"]
|
||||
)
|
||||
self.assertEqual(
|
||||
leases.classify_lease_freshness(lease),
|
||||
"stale_warning",
|
||||
)
|
||||
|
||||
def test_reclaimable_after_60_minutes(self):
|
||||
lease = leases.parse_lease_comment(
|
||||
_lease_comment(382, "session-a", minutes_ago=65)["body"]
|
||||
)
|
||||
self.assertEqual(
|
||||
leases.classify_lease_freshness(lease),
|
||||
"reclaimable",
|
||||
)
|
||||
|
||||
|
||||
class TestReviewerLeaseMutationGate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
leases.clear_session_lease()
|
||||
|
||||
def test_reviewer_without_lease_cannot_mutate(self):
|
||||
head = "f" * 40
|
||||
comments = [_lease_comment(382, "other-session", candidate_head=head)]
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=382,
|
||||
comments=comments,
|
||||
reviewer_identity="rev1",
|
||||
session_id="my-session",
|
||||
mutation="approve",
|
||||
live_head_sha=head,
|
||||
pinned_head_sha=head,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_owned_lease_allows_mutation(self):
|
||||
head = "f" * 40
|
||||
comments = [_lease_comment(382, "my-session", candidate_head=head)]
|
||||
leases.record_session_lease({
|
||||
"pr_number": 382,
|
||||
"session_id": "my-session",
|
||||
"candidate_head": head,
|
||||
"target_branch": "master",
|
||||
})
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=382,
|
||||
comments=comments,
|
||||
reviewer_identity="rev1",
|
||||
session_id="my-session",
|
||||
mutation="approve",
|
||||
live_head_sha=head,
|
||||
pinned_head_sha=head,
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_head_change_invalidates_lease(self):
|
||||
reviewed = "f" * 40
|
||||
live = "e" * 40
|
||||
comments = [_lease_comment(382, "my-session", candidate_head=reviewed)]
|
||||
leases.record_session_lease({
|
||||
"pr_number": 382,
|
||||
"session_id": "my-session",
|
||||
"candidate_head": reviewed,
|
||||
})
|
||||
result = leases.assess_mutation_lease_gate(
|
||||
pr_number=382,
|
||||
comments=comments,
|
||||
reviewer_identity="rev1",
|
||||
session_id="my-session",
|
||||
mutation="merge",
|
||||
live_head_sha=live,
|
||||
pinned_head_sha=reviewed,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertTrue(any("head" in r.lower() for r in result["reasons"]))
|
||||
|
||||
|
||||
class TestReviewerLeaseMcpGate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
leases.clear_session_lease()
|
||||
patch("mcp_server.verify_preflight_purity").start()
|
||||
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||
mcp_server = __import__("mcp_server")
|
||||
mcp_server._IDENTITY_CACHE.clear()
|
||||
mcp_server.init_review_decision_lock("prgs", "review_pr")
|
||||
mcp_server.record_preflight_check("whoami")
|
||||
mcp_server.record_preflight_check("capability", "reviewer")
|
||||
|
||||
def tearDown(self):
|
||||
patch.stopall()
|
||||
leases.clear_session_lease()
|
||||
|
||||
def test_reviewer_pr_lease_gate_helper_blocks_without_session(self):
|
||||
import mcp_server
|
||||
head = "a" * 40
|
||||
with patch("mcp_server._fetch_pr_comments", return_value=[]):
|
||||
reasons = mcp_server._reviewer_pr_lease_gate(
|
||||
pr_number=382,
|
||||
remote="prgs",
|
||||
host=None,
|
||||
org=None,
|
||||
repo=None,
|
||||
mutation="approve",
|
||||
live_head_sha=head,
|
||||
pinned_head_sha=head,
|
||||
)
|
||||
self.assertTrue(any("lease" in r.lower() for r in reasons))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Tests for validation cwd/HEAD proof verifier (#398)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report # noqa: E402
|
||||
|
||||
ROOT = "/Users/jasonwalker/Development/Gitea-Tools"
|
||||
WORKTREE = f"{ROOT}/branches/review-feat-issue-398"
|
||||
HEAD = "f5953549aad5e822f14f52d3ea3c6d7990109384"
|
||||
|
||||
|
||||
def _proof_backed_report() -> str:
|
||||
return "\n".join([
|
||||
f"Candidate head SHA: {HEAD}",
|
||||
f"pwd: {WORKTREE}",
|
||||
f"git rev-parse HEAD: {HEAD}",
|
||||
"git status --short --branch: ## feat/issue-398...prgs/master",
|
||||
f"Validation command: cd {WORKTREE} && venv/bin/python -m pytest tests/ -q",
|
||||
"Result: 1497 passed, 6 skipped",
|
||||
])
|
||||
|
||||
|
||||
class TestValidationCwdProof(unittest.TestCase):
|
||||
def test_no_validation_claim_passes(self):
|
||||
result = assess_validation_cwd_proof_report("Review decision: approve")
|
||||
self.assertTrue(result["proven"])
|
||||
|
||||
def test_missing_cwd_proof_fails(self):
|
||||
result = assess_validation_cwd_proof_report(
|
||||
f"Validation command: pytest tests/\nCandidate head SHA: {HEAD}",
|
||||
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_main_checkout_cwd_blocks(self):
|
||||
result = assess_validation_cwd_proof_report(
|
||||
"\n".join([
|
||||
f"Candidate head SHA: {HEAD}",
|
||||
f"pwd: {ROOT}",
|
||||
f"git rev-parse HEAD: {HEAD}",
|
||||
"git status --short --branch: ## master",
|
||||
"Validation command: pytest tests/ -q",
|
||||
]),
|
||||
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
|
||||
project_root=ROOT,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["violations"])
|
||||
|
||||
def test_wrong_head_blocks(self):
|
||||
wrong = "a" * 40
|
||||
result = assess_validation_cwd_proof_report(
|
||||
"\n".join([
|
||||
f"Candidate head SHA: {HEAD}",
|
||||
f"pwd: {WORKTREE}",
|
||||
f"git rev-parse HEAD: {wrong}",
|
||||
"git status --short --branch: clean",
|
||||
f"Validation command: cd {WORKTREE} && pytest -q",
|
||||
]),
|
||||
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
|
||||
project_root=ROOT,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(result["violations"])
|
||||
|
||||
def test_fully_proof_backed_passes(self):
|
||||
result = assess_validation_cwd_proof_report(
|
||||
_proof_backed_report(),
|
||||
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
|
||||
project_root=ROOT,
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_baseline_without_cwd_fails(self):
|
||||
result = assess_validation_cwd_proof_report(
|
||||
"\n".join([
|
||||
_proof_backed_report(),
|
||||
"Baseline validation command: pytest tests/ -q",
|
||||
]),
|
||||
validation_session={
|
||||
"validation_ran": True,
|
||||
"expected_head_sha": HEAD,
|
||||
"baseline_validation_ran": True,
|
||||
},
|
||||
project_root=ROOT,
|
||||
)
|
||||
self.assertFalse(result["proven"])
|
||||
self.assertTrue(
|
||||
any("baseline" in r.lower() for r in result["reasons"])
|
||||
)
|
||||
|
||||
def test_baseline_with_full_proof_passes(self):
|
||||
result = assess_validation_cwd_proof_report(
|
||||
"\n".join([
|
||||
_proof_backed_report(),
|
||||
f"Baseline worktree: {ROOT}/branches/baseline-master-pr376",
|
||||
f"Baseline target SHA: {HEAD}",
|
||||
f"Baseline validation command: cd {ROOT}/branches/baseline-master-pr376 && pytest -q",
|
||||
]),
|
||||
validation_session={
|
||||
"validation_ran": True,
|
||||
"expected_head_sha": HEAD,
|
||||
"baseline_validation_ran": True,
|
||||
},
|
||||
project_root=ROOT,
|
||||
)
|
||||
self.assertTrue(result["proven"], result["reasons"])
|
||||
|
||||
def test_final_report_validator_integration(self):
|
||||
result = assess_final_report_validator(
|
||||
"Validation command: pytest tests/ -q",
|
||||
"review_pr",
|
||||
validation_session={"validation_ran": True},
|
||||
)
|
||||
self.assertTrue(result["blocked"] or result["downgraded"])
|
||||
self.assertTrue(
|
||||
any(
|
||||
f.get("rule_id") == "reviewer.validation_cwd_proof"
|
||||
for f in result.get("findings") or []
|
||||
)
|
||||
)
|
||||
|
||||
def test_exported_from_review_proofs(self):
|
||||
from review_proofs import assess_validation_cwd_proof_report as exported
|
||||
|
||||
self.assertTrue(callable(exported))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Unit tests for stacked-PR base policy (#484)."""
|
||||
import unittest
|
||||
|
||||
import stacked_pr_support as sps
|
||||
|
||||
|
||||
def _pr(number, branch, state="open"):
|
||||
return {"number": number, "state": state, "head": {"ref": branch}}
|
||||
|
||||
|
||||
OPEN_PRS = [
|
||||
_pr(479, "feat/issue-478-mcp-menu-shell"),
|
||||
_pr(481, "feat/issue-477-lock-adoption-proof"),
|
||||
]
|
||||
|
||||
# Motivating case (#482 stacked on #479 / #478).
|
||||
STACKED_BODY = (
|
||||
"Closes #482.\n\n"
|
||||
"Stacked on PR #479 / issue #478.\n"
|
||||
"Base branch: feat/issue-478-mcp-menu-shell\n"
|
||||
"Head branch: feat/issue-482-skip-stale-request-changes-pr\n"
|
||||
"Do not merge before PR #479 lands."
|
||||
)
|
||||
|
||||
|
||||
class TestIsBaseBranch(unittest.TestCase):
|
||||
def test_master_main_dev_are_base(self):
|
||||
for b in ("master", "main", "dev"):
|
||||
self.assertTrue(sps.is_base_branch(b))
|
||||
|
||||
def test_feature_branch_is_not_base(self):
|
||||
self.assertFalse(sps.is_base_branch("feat/issue-478-mcp-menu-shell"))
|
||||
|
||||
|
||||
class TestStackedBaseDeclaration(unittest.TestCase):
|
||||
def test_no_declaration_is_normal_path(self):
|
||||
out = sps.assess_stacked_base_declaration(
|
||||
stacked_base_branch=None, stacked_base_pr=None, open_prs=OPEN_PRS
|
||||
)
|
||||
self.assertFalse(out["block"])
|
||||
self.assertFalse(out["declared"])
|
||||
self.assertIsNone(out["approved"])
|
||||
|
||||
def test_valid_open_pr_base_is_approved(self):
|
||||
out = sps.assess_stacked_base_declaration(
|
||||
stacked_base_branch="feat/issue-478-mcp-menu-shell",
|
||||
stacked_base_pr=479,
|
||||
open_prs=OPEN_PRS,
|
||||
)
|
||||
self.assertFalse(out["block"])
|
||||
self.assertEqual(out["approved"]["branch"], "feat/issue-478-mcp-menu-shell")
|
||||
self.assertEqual(out["approved"]["pr_number"], 479)
|
||||
self.assertTrue(out["approved"]["verified_open"])
|
||||
|
||||
def test_missing_pr_number_blocks(self):
|
||||
out = sps.assess_stacked_base_declaration(
|
||||
stacked_base_branch="feat/issue-478-mcp-menu-shell",
|
||||
stacked_base_pr=None,
|
||||
open_prs=OPEN_PRS,
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIn("without stacked_base_pr", out["reasons"][0])
|
||||
|
||||
def test_arbitrary_branch_with_no_open_pr_blocks(self):
|
||||
out = sps.assess_stacked_base_declaration(
|
||||
stacked_base_branch="feat/random-unrelated-branch",
|
||||
stacked_base_pr=999,
|
||||
open_prs=OPEN_PRS,
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIn("does not correspond to any OPEN pull request", out["reasons"][0])
|
||||
|
||||
def test_stale_merged_base_blocks(self):
|
||||
merged = [_pr(479, "feat/issue-478-mcp-menu-shell", state="closed")]
|
||||
out = sps.assess_stacked_base_declaration(
|
||||
stacked_base_branch="feat/issue-478-mcp-menu-shell",
|
||||
stacked_base_pr=479,
|
||||
open_prs=merged,
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIsNone(out["approved"])
|
||||
|
||||
def test_pr_number_mismatch_blocks(self):
|
||||
out = sps.assess_stacked_base_declaration(
|
||||
stacked_base_branch="feat/issue-478-mcp-menu-shell",
|
||||
stacked_base_pr=481, # wrong PR for this branch
|
||||
open_prs=OPEN_PRS,
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIn("does not match the open", out["reasons"][0])
|
||||
|
||||
def test_declaring_a_base_branch_blocks(self):
|
||||
out = sps.assess_stacked_base_declaration(
|
||||
stacked_base_branch="master", stacked_base_pr=1, open_prs=OPEN_PRS
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIn("already a normal base branch", out["reasons"][0])
|
||||
|
||||
|
||||
class TestStackedPrBody(unittest.TestCase):
|
||||
def test_complete_body_has_no_missing_fields(self):
|
||||
missing = sps.assess_stacked_pr_body(
|
||||
STACKED_BODY, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
|
||||
)
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_missing_all_fields(self):
|
||||
missing = sps.assess_stacked_pr_body(
|
||||
"just some text", base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
|
||||
)
|
||||
self.assertEqual(len(missing), 3)
|
||||
|
||||
def test_missing_merge_ordering_only(self):
|
||||
body = "Base branch feat/issue-478-mcp-menu-shell for PR #479"
|
||||
missing = sps.assess_stacked_pr_body(
|
||||
body, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
|
||||
)
|
||||
self.assertEqual(len(missing), 1)
|
||||
self.assertIn("merge-ordering", missing[0])
|
||||
|
||||
|
||||
class TestCreatePrBase(unittest.TestCase):
|
||||
APPROVED = {"branch": "feat/issue-478-mcp-menu-shell", "pr_number": 479, "verified_open": True}
|
||||
|
||||
def test_master_base_passes_without_stacked_metadata(self):
|
||||
out = sps.assess_create_pr_base(
|
||||
base="master", approved_stacked_base=None, body="Closes #1", open_prs=[]
|
||||
)
|
||||
self.assertFalse(out["block"])
|
||||
self.assertFalse(out["stacked"])
|
||||
|
||||
def test_non_base_without_approval_blocks(self):
|
||||
out = sps.assess_create_pr_base(
|
||||
base="feat/issue-478-mcp-menu-shell",
|
||||
approved_stacked_base=None,
|
||||
body=STACKED_BODY,
|
||||
open_prs=OPEN_PRS,
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIn("no approved stacked base", out["reasons"][0])
|
||||
|
||||
def test_non_base_mismatched_approval_blocks(self):
|
||||
out = sps.assess_create_pr_base(
|
||||
base="feat/some-other-branch",
|
||||
approved_stacked_base=self.APPROVED,
|
||||
body=STACKED_BODY,
|
||||
open_prs=OPEN_PRS,
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIn("does not match the issue lock's approved stacked base", out["reasons"][0])
|
||||
|
||||
def test_approved_base_with_good_body_passes(self):
|
||||
out = sps.assess_create_pr_base(
|
||||
base="feat/issue-478-mcp-menu-shell",
|
||||
approved_stacked_base=self.APPROVED,
|
||||
body=STACKED_BODY,
|
||||
open_prs=OPEN_PRS,
|
||||
)
|
||||
self.assertFalse(out["block"])
|
||||
self.assertTrue(out["stacked"])
|
||||
self.assertEqual(out["stacked_base_pr"], 479)
|
||||
|
||||
def test_approved_base_now_stale_blocks(self):
|
||||
out = sps.assess_create_pr_base(
|
||||
base="feat/issue-478-mcp-menu-shell",
|
||||
approved_stacked_base=self.APPROVED,
|
||||
body=STACKED_BODY,
|
||||
open_prs=[_pr(479, "feat/issue-478-mcp-menu-shell", state="merged")],
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIn("no longer corresponds to an OPEN", out["reasons"][0])
|
||||
|
||||
def test_approved_base_with_incomplete_body_blocks(self):
|
||||
out = sps.assess_create_pr_base(
|
||||
base="feat/issue-478-mcp-menu-shell",
|
||||
approved_stacked_base=self.APPROVED,
|
||||
body="Closes #482 only",
|
||||
open_prs=OPEN_PRS,
|
||||
)
|
||||
self.assertTrue(out["block"])
|
||||
self.assertIn("must document the stack", out["reasons"][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,8 +12,6 @@ from unittest.mock import patch
|
||||
|
||||
import mcp_server
|
||||
|
||||
HEAD_SHA = "a" * 40
|
||||
|
||||
|
||||
def _lock(mutations=None, correction=False):
|
||||
return {
|
||||
@@ -129,20 +127,10 @@ def _feedback(blocking, stale=False, success=True):
|
||||
"success": success,
|
||||
"has_blocking_change_requests": blocking,
|
||||
"review_feedback_stale": stale,
|
||||
"current_head_sha": HEAD_SHA,
|
||||
"current_head_sha": "abc123",
|
||||
}
|
||||
|
||||
|
||||
def _mark(action, pr_number=6, **kwargs):
|
||||
kwargs.setdefault("expected_head_sha", HEAD_SHA)
|
||||
no_lease_block = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
with patch("mcp_server._list_pr_lease_comments", return_value=[]), \
|
||||
patch("mcp_server._pr_work_lease_reviewer_block", return_value=no_lease_block):
|
||||
return mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=pr_number, action=action, remote="prgs", **kwargs
|
||||
)
|
||||
|
||||
|
||||
class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
@@ -151,7 +139,8 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
||||
_seed()
|
||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||
return_value=_feedback(blocking=True, stale=False)):
|
||||
result = _mark("request_changes")
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="request_changes", remote="prgs")
|
||||
self.assertFalse(result["marked_ready"])
|
||||
self.assertTrue(
|
||||
any("duplicate" in r for r in result["reasons"]),
|
||||
@@ -161,14 +150,16 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
||||
_seed()
|
||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||
return_value=_feedback(blocking=True, stale=True)):
|
||||
result = _mark("request_changes")
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="request_changes", remote="prgs")
|
||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||
|
||||
def test_request_changes_allowed_when_no_blocker(self):
|
||||
_seed()
|
||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||
return_value=_feedback(blocking=False)):
|
||||
result = _mark("request_changes")
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="request_changes", remote="prgs")
|
||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||
|
||||
def test_request_changes_fails_closed_when_feedback_unavailable(self):
|
||||
@@ -176,7 +167,8 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||
return_value=_feedback(blocking=False,
|
||||
success=False)):
|
||||
result = _mark("request_changes")
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="request_changes", remote="prgs")
|
||||
self.assertFalse(result["marked_ready"])
|
||||
self.assertTrue(
|
||||
any("could not verify" in r for r in result["reasons"]),
|
||||
@@ -186,7 +178,8 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
|
||||
_seed()
|
||||
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
|
||||
side_effect=AssertionError("must not be called")):
|
||||
result = _mark("approve")
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="approve", remote="prgs")
|
||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||
|
||||
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
"""Tests for validation status vocabulary (#406)."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||
from validation_status_vocabulary import ( # noqa: E402
|
||||
STATUS_BASELINE_EQUIVALENT,
|
||||
STATUS_FAILED,
|
||||
STATUS_MERGE_SIM_RESOLVED,
|
||||
STATUS_PASSED,
|
||||
STATUS_TRANSIENT_PASS,
|
||||
assess_validation_status_vocabulary,
|
||||
)
|
||||
|
||||
|
||||
def _handoff(**extra):
|
||||
fields = {
|
||||
"Task": "review PR #386",
|
||||
"Validation status": STATUS_PASSED,
|
||||
"Raw PR-head validation result": "passed",
|
||||
"Merge simulation result": "not run",
|
||||
"Baseline worktree used": "none",
|
||||
}
|
||||
fields.update(extra)
|
||||
lines = ["## Controller Handoff", ""]
|
||||
lines.extend(f"- {key}: {value}" for key, value in fields.items())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TestValidationStatusVocabulary(unittest.TestCase):
|
||||
def test_raw_head_pass_status(self):
|
||||
report = _handoff()
|
||||
result = assess_validation_status_vocabulary(report)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertEqual(result["status_claimed"], STATUS_PASSED)
|
||||
|
||||
def test_raw_head_failure_with_baseline_match(self):
|
||||
report = _handoff(
|
||||
**{
|
||||
"Validation status": STATUS_BASELINE_EQUIVALENT,
|
||||
"Raw PR-head validation result": "failed",
|
||||
"Baseline worktree used": "branches/baseline-master-pr386",
|
||||
"Baseline target SHA": "a" * 40,
|
||||
"Baseline failures": "test_foo failed",
|
||||
"PR failures": "test_foo failed",
|
||||
"Failure signatures match": "true",
|
||||
}
|
||||
)
|
||||
result = assess_validation_status_vocabulary(report)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["baseline_proof_complete"])
|
||||
|
||||
def test_baseline_equivalent_without_baseline_proof_blocked(self):
|
||||
report = _handoff(
|
||||
**{
|
||||
"Validation status": STATUS_BASELINE_EQUIVALENT,
|
||||
"Raw PR-head validation result": "failed",
|
||||
}
|
||||
)
|
||||
result = assess_validation_status_vocabulary(report)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("baseline-equivalent", result["reasons"][0])
|
||||
|
||||
def test_merge_simulation_resolution_passes(self):
|
||||
report = "\n".join([
|
||||
_handoff(
|
||||
**{
|
||||
"Validation status": STATUS_MERGE_SIM_RESOLVED,
|
||||
"Raw PR-head validation result": "failed",
|
||||
"Merge simulation result": "passed",
|
||||
}
|
||||
),
|
||||
"Worktree/index mutations: merge simulation in branches/review-pr386",
|
||||
"Worktree path: branches/review-pr386",
|
||||
"Pre-simulation clean status: clean",
|
||||
"Merge result: clean merge",
|
||||
"Abort command: git merge --abort",
|
||||
"Post-abort clean status: clean",
|
||||
])
|
||||
command_log = [
|
||||
{"command": "git merge --no-commit prgs/master"},
|
||||
{"command": "git merge --abort"},
|
||||
]
|
||||
result = assess_validation_status_vocabulary(
|
||||
report, command_log=command_log
|
||||
)
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(result["merge_simulation_passed"])
|
||||
|
||||
def test_merge_simulation_failure_stays_failed(self):
|
||||
report = _handoff(
|
||||
**{
|
||||
"Validation status": STATUS_FAILED,
|
||||
"Raw PR-head validation result": "failed",
|
||||
"Merge simulation result": "failed",
|
||||
}
|
||||
)
|
||||
result = assess_validation_status_vocabulary(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_failed_status_with_passing_merge_sim_blocked(self):
|
||||
report = "\n".join([
|
||||
_handoff(
|
||||
**{
|
||||
"Validation status": STATUS_FAILED,
|
||||
"Raw PR-head validation result": "failed",
|
||||
"Merge simulation result": "passed",
|
||||
}
|
||||
),
|
||||
"Worktree/index mutations: merge simulation",
|
||||
"Worktree path: branches/review-pr386",
|
||||
"Pre-simulation clean status: clean",
|
||||
"Merge result: clean",
|
||||
"Abort command: git merge --abort",
|
||||
"Post-abort clean status: clean",
|
||||
])
|
||||
result = assess_validation_status_vocabulary(
|
||||
report,
|
||||
command_log=[{"command": "git merge --no-commit prgs/master"}],
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_transient_failure_then_pass(self):
|
||||
report = _handoff(
|
||||
**{
|
||||
"Validation status": STATUS_TRANSIENT_PASS,
|
||||
"Raw PR-head validation result": "passed",
|
||||
"Transient validation failure history": (
|
||||
"first run failed with infra flake; rerun passed"
|
||||
),
|
||||
}
|
||||
)
|
||||
result = assess_validation_status_vocabulary(report)
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_transient_pass_without_history_blocked(self):
|
||||
report = _handoff(
|
||||
**{
|
||||
"Validation status": STATUS_TRANSIENT_PASS,
|
||||
"Raw PR-head validation result": "passed",
|
||||
}
|
||||
)
|
||||
result = assess_validation_status_vocabulary(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_bare_passed_after_raw_failure_blocked(self):
|
||||
report = _handoff(
|
||||
**{
|
||||
"Validation status": STATUS_PASSED,
|
||||
"Raw PR-head validation result": "failed",
|
||||
}
|
||||
)
|
||||
result = assess_validation_status_vocabulary(report)
|
||||
self.assertTrue(result["block"])
|
||||
|
||||
def test_wrong_baseline_label_when_merge_sim_used_blocked(self):
|
||||
report = "\n".join([
|
||||
_handoff(
|
||||
**{
|
||||
"Validation status": STATUS_BASELINE_EQUIVALENT,
|
||||
"Raw PR-head validation result": "failed",
|
||||
"Merge simulation result": "passed",
|
||||
}
|
||||
),
|
||||
"Worktree/index mutations: merge simulation",
|
||||
"Worktree path: branches/review-pr386",
|
||||
"Pre-simulation clean status: clean",
|
||||
"Merge result: clean",
|
||||
"Abort command: git merge --abort",
|
||||
"Post-abort clean status: clean",
|
||||
])
|
||||
result = assess_validation_status_vocabulary(
|
||||
report,
|
||||
command_log=[{"command": "git merge --no-commit prgs/master"}],
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
joined = " ".join(result["reasons"]).lower()
|
||||
self.assertTrue(
|
||||
"misleading" in joined or "baseline-equivalent" in joined
|
||||
)
|
||||
|
||||
def test_final_report_validator_integration_blocks_misleading_label(self):
|
||||
report = _handoff(
|
||||
**{
|
||||
"Validation status": STATUS_BASELINE_EQUIVALENT,
|
||||
"Raw PR-head validation result": "failed",
|
||||
}
|
||||
)
|
||||
result = assess_final_report_validator(report, "review_pr")
|
||||
blocked_ids = {f["rule_id"] for f in result["findings"]}
|
||||
self.assertIn("reviewer.validation_status_vocabulary", blocked_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,14 +12,12 @@ from starlette.testclient import TestClient
|
||||
from webui.app import create_app
|
||||
from webui.queue_loader import (
|
||||
PaginationMeta,
|
||||
QueueSnapshot,
|
||||
_classify_issue,
|
||||
_classify_pr,
|
||||
_extract_linked_issue,
|
||||
load_queue_snapshot,
|
||||
snapshot_to_dict,
|
||||
)
|
||||
from webui.queue_views import render_queue_page
|
||||
|
||||
_RECENT = datetime.now(timezone.utc).isoformat()
|
||||
_STALE = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
||||
@@ -211,77 +209,36 @@ class TestQueueRoutes(unittest.TestCase):
|
||||
self.assertIsNotNone(snapshot.fetch_error)
|
||||
self.assertEqual(len(snapshot.prs), 0)
|
||||
|
||||
|
||||
def _empty_pagination() -> PaginationMeta:
|
||||
return PaginationMeta(
|
||||
page=1,
|
||||
per_page=50,
|
||||
returned_count=0,
|
||||
has_more=False,
|
||||
is_final_page=True,
|
||||
inventory_complete=True,
|
||||
pages_fetched=1,
|
||||
)
|
||||
|
||||
|
||||
def _empty_fetch(*_args, **_kwargs):
|
||||
return [], _empty_pagination()
|
||||
|
||||
|
||||
class TestQueueFailClosedUx(unittest.TestCase):
|
||||
"""Regression tests for #458 fail-closed empty-state copy."""
|
||||
|
||||
def test_fail_closed_view_suppresses_empty_queue_copy(self):
|
||||
snapshot = QueueSnapshot(
|
||||
project_id="gitea-tools",
|
||||
repo_label="Scaled-Tech-Consulting/Gitea-Tools",
|
||||
prs=(),
|
||||
issues=(),
|
||||
pr_pagination=None,
|
||||
issue_pagination=None,
|
||||
fetch_error="Gitea credentials unavailable for gitea.prgs.cc",
|
||||
)
|
||||
html = render_queue_page(snapshot)
|
||||
self.assertIn("Queue unavailable", html)
|
||||
self.assertIn("Not loaded", html)
|
||||
self.assertNotIn("No open items.", html)
|
||||
self.assertIn("pagination:</strong> unavailable", html)
|
||||
|
||||
def test_fail_closed_route_does_not_show_empty_queue_copy(self):
|
||||
client = TestClient(create_app())
|
||||
snapshot = load_queue_snapshot(
|
||||
fetch_prs=_empty_fetch,
|
||||
fetch_issues=_empty_fetch,
|
||||
)
|
||||
snapshot = QueueSnapshot(
|
||||
project_id=snapshot.project_id,
|
||||
repo_label=snapshot.repo_label,
|
||||
prs=(),
|
||||
issues=(),
|
||||
pr_pagination=None,
|
||||
issue_pagination=None,
|
||||
fetch_error="Gitea credentials unavailable for gitea.prgs.cc",
|
||||
)
|
||||
def test_queue_fail_closed_hides_empty_state_copy(self):
|
||||
with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
|
||||
snapshot = load_queue_snapshot()
|
||||
with mock.patch("webui.app.load_queue_snapshot", return_value=snapshot):
|
||||
response = client.get("/queue")
|
||||
response = self.client.get("/queue")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Queue unavailable", response.text)
|
||||
self.assertIn("not fetched", response.text.lower())
|
||||
self.assertNotIn("No open items.", response.text)
|
||||
self.assertNotIn("Open pull requests", response.text)
|
||||
self.assertNotIn("pages_fetched", response.text)
|
||||
|
||||
def test_queue_successful_empty_inventory_shows_empty_state(self):
|
||||
def _empty_prs(*_args, **_kwargs):
|
||||
return [], _mock_pagination(0)
|
||||
|
||||
def _empty_issues(*_args, **_kwargs):
|
||||
return [], _mock_pagination(0)
|
||||
|
||||
def test_successful_empty_inventory_shows_empty_copy(self):
|
||||
snapshot = load_queue_snapshot(
|
||||
fetch_prs=_empty_fetch,
|
||||
fetch_issues=_empty_fetch,
|
||||
fetch_prs=_empty_prs,
|
||||
fetch_issues=_empty_issues,
|
||||
)
|
||||
with mock.patch("webui.app.load_queue_snapshot", return_value=snapshot):
|
||||
response = self.client.get("/queue")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(snapshot.fetch_error)
|
||||
self.assertEqual(len(snapshot.prs), 0)
|
||||
self.assertEqual(len(snapshot.issues), 0)
|
||||
self.assertTrue(snapshot.pr_pagination.inventory_complete)
|
||||
|
||||
html = render_queue_page(snapshot)
|
||||
self.assertNotIn("Queue unavailable", html)
|
||||
self.assertEqual(html.count("No open items."), 2)
|
||||
self.assertIn("pagination (complete)", html)
|
||||
self.assertEqual(response.text.count("No open items."), 2)
|
||||
self.assertIn("pages_fetched", response.text)
|
||||
self.assertIn("pagination (complete)", response.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Tests for runtime_context / mutation-guard workspace alignment (#460)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import author_mutation_worktree as amw # noqa: E402
|
||||
import gitea_mcp_server as srv # noqa: E402
|
||||
|
||||
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
|
||||
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
|
||||
MCP_PROCESS_ROOT = BRANCHES_WORKTREE
|
||||
|
||||
|
||||
class TestCanonicalRepoRoot(unittest.TestCase):
|
||||
@mock.patch("subprocess.run")
|
||||
def test_resolves_main_repo_from_branches_worktree(self, mock_run):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||
)
|
||||
root = amw.resolve_canonical_repo_root(BRANCHES_WORKTREE, MCP_PROCESS_ROOT)
|
||||
self.assertEqual(root, CONTROL_ROOT)
|
||||
|
||||
def test_falls_back_when_git_unavailable(self):
|
||||
root = amw.resolve_canonical_repo_root("/missing/path", MCP_PROCESS_ROOT)
|
||||
self.assertEqual(root, os.path.realpath(MCP_PROCESS_ROOT))
|
||||
|
||||
|
||||
class TestWorkspaceRepoMembership(unittest.TestCase):
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
@mock.patch("subprocess.run")
|
||||
def test_valid_branches_worktree_accepted(self, mock_run, *_exists):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||
)
|
||||
result = amw.assess_workspace_repo_membership(
|
||||
workspace_path=BRANCHES_WORKTREE,
|
||||
canonical_repo_root=CONTROL_ROOT,
|
||||
)
|
||||
self.assertTrue(result["proven"])
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
@mock.patch("subprocess.run")
|
||||
def test_wrong_repo_rejected(self, mock_run, *_exists):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="/other/repo/.git\n",
|
||||
)
|
||||
result = amw.assess_workspace_repo_membership(
|
||||
workspace_path=BRANCHES_WORKTREE,
|
||||
canonical_repo_root=CONTROL_ROOT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("does not belong", result["reasons"][0])
|
||||
|
||||
@mock.patch("os.path.exists", return_value=False)
|
||||
def test_missing_worktree_rejected(self, *_exists):
|
||||
result = amw.assess_workspace_repo_membership(
|
||||
workspace_path=f"{CONTROL_ROOT}/branches/missing-worktree",
|
||||
canonical_repo_root=CONTROL_ROOT,
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertIn("does not exist", result["reasons"][0])
|
||||
|
||||
|
||||
class TestRuntimeContextGuardAlignment(unittest.TestCase):
|
||||
def setUp(self):
|
||||
srv._preflight_whoami_called = True
|
||||
srv._preflight_capability_called = True
|
||||
srv._preflight_resolved_role = "author"
|
||||
self._orig_in_test = srv._preflight_in_test_mode
|
||||
srv._preflight_in_test_mode = lambda: False
|
||||
self._env_patch = mock.patch.dict(
|
||||
os.environ,
|
||||
{},
|
||||
clear=False,
|
||||
)
|
||||
self._env_patch.start()
|
||||
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
|
||||
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
|
||||
|
||||
def tearDown(self):
|
||||
srv._preflight_in_test_mode = self._orig_in_test
|
||||
self._env_patch.stop()
|
||||
|
||||
def test_runtime_context_and_guard_share_resolved_workspace(self):
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
|
||||
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
||||
self.assertEqual(ctx["workspace_path"], os.path.realpath(BRANCHES_WORKTREE))
|
||||
self.assertEqual(ctx["canonical_repo_root"], CONTROL_ROOT)
|
||||
self.assertFalse(ctx["roots_aligned"])
|
||||
self.assertEqual(
|
||||
status["preflight_workspace"]["active_task_workspace_root"],
|
||||
os.path.realpath(BRANCHES_WORKTREE),
|
||||
)
|
||||
self.assertEqual(
|
||||
status["preflight_workspace"]["canonical_repository_root"],
|
||||
CONTROL_ROOT,
|
||||
)
|
||||
self.assertIn("workspace_root_mismatch", status["preflight_workspace"])
|
||||
|
||||
@mock.patch("subprocess.run")
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
def test_declared_branches_worktree_passes_when_mcp_root_differs(
|
||||
self, _exists, _isdir, mock_run
|
||||
):
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||
)
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
|
||||
srv.verify_preflight_purity(worktree_path=BRANCHES_WORKTREE)
|
||||
|
||||
def test_stable_checkout_still_rejected(self):
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
|
||||
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity()
|
||||
self.assertIn("stable control checkout", str(ctx.exception))
|
||||
|
||||
@mock.patch("os.path.isdir", return_value=True)
|
||||
@mock.patch("os.path.exists", return_value=True)
|
||||
@mock.patch("subprocess.run")
|
||||
def test_non_branches_worktree_rejected(self, mock_run, *_exists):
|
||||
outside = "/tmp/outside-repo-checkout"
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||
)
|
||||
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
|
||||
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
srv.verify_preflight_purity(worktree_path=outside)
|
||||
self.assertIn("not under", str(ctx.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+9
-26
@@ -20,50 +20,33 @@ def run(script, *args):
|
||||
branch = arg
|
||||
break
|
||||
|
||||
lock_dir_ctx = None
|
||||
extra_env = os.environ.copy()
|
||||
lock_file = Path("/tmp/gitea_issue_lock.json")
|
||||
created_lock = False
|
||||
if script == "worktree-start" and branch:
|
||||
import re
|
||||
import tempfile
|
||||
import issue_lock_store
|
||||
|
||||
import json
|
||||
m = re.search(r"issue-(\d+)", branch)
|
||||
if not m:
|
||||
m = re.search(r"pr-(\d+)", branch)
|
||||
issue_num = int(m.group(1)) if m else 999
|
||||
lock_dir_ctx = tempfile.TemporaryDirectory()
|
||||
extra_env["GITEA_ISSUE_LOCK_DIR"] = lock_dir_ctx.name
|
||||
record = {
|
||||
lock_file.write_text(json.dumps({
|
||||
"issue_number": issue_num,
|
||||
"branch_name": branch,
|
||||
"remote": "prgs",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
"repo": "Gitea-Tools",
|
||||
"worktree_path": "/tmp/test-worktree",
|
||||
"work_lease": {
|
||||
"operation_type": "author_issue_work",
|
||||
"expires_at": "2999-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
path = issue_lock_store.lock_file_path(
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
issue_number=issue_num,
|
||||
lock_dir=lock_dir_ctx.name,
|
||||
)
|
||||
issue_lock_store.save_lock_file(path, record)
|
||||
"repo": "Gitea-Tools"
|
||||
}), encoding="utf-8")
|
||||
created_lock = True
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["bash", str(SCRIPTS / script), *args],
|
||||
capture_output=True, text=True, cwd=str(REPO),
|
||||
env=extra_env,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
finally:
|
||||
if lock_dir_ctx is not None:
|
||||
lock_dir_ctx.cleanup()
|
||||
if created_lock and lock_file.exists():
|
||||
lock_file.unlink()
|
||||
|
||||
|
||||
class TestWorktreeStart(unittest.TestCase):
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
"""Precise validation-status vocabulary for reviewer final reports (#406)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from reviewer_merge_simulation import assess_merge_simulation_report
|
||||
|
||||
STATUS_PASSED = "passed"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_BASELINE_EQUIVALENT = "baseline-equivalent failure accepted"
|
||||
STATUS_MERGE_SIM_RESOLVED = "raw-head failure resolved by merge simulation"
|
||||
STATUS_TRANSIENT_PASS = "passed after transient failure investigation"
|
||||
|
||||
ALLOWED_VALIDATION_STATUSES = frozenset({
|
||||
STATUS_PASSED,
|
||||
STATUS_FAILED,
|
||||
STATUS_BASELINE_EQUIVALENT,
|
||||
STATUS_MERGE_SIM_RESOLVED,
|
||||
STATUS_TRANSIENT_PASS,
|
||||
})
|
||||
|
||||
_STATUS_FIELD_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:validation status|pr-head validation status|"
|
||||
r"official validation status)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_RAW_HEAD_RESULT_RE = re.compile(
|
||||
r"^\s*[-*]?\s*raw pr-head validation result\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_MERGE_SIM_RESULT_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge simulation result\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BASELINE_WORKTREE_USED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*baseline (?:validation )?worktree(?: used)?\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BASELINE_TARGET_SHA_RE = re.compile(
|
||||
r"^\s*[-*]?\s*baseline target sha\s*:\s*([0-9a-f]{7,40})\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FAILURE_SIGNATURE_RE = re.compile(
|
||||
r"failure signatures match\s*:\s*(true|yes)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BASELINE_FAILURES_RE = re.compile(
|
||||
r"baseline failures\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TRANSIENT_HISTORY_RE = re.compile(
|
||||
r"(?:transient validation failure|earlier validation failure|"
|
||||
r"prior failure|failure history|failed then passed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _first_match(pattern: re.Pattern[str], text: str) -> str:
|
||||
match = pattern.search(text or "")
|
||||
return (match.group(1).strip() if match else "")
|
||||
|
||||
|
||||
def _normalize_status_label(raw: str) -> str:
|
||||
text = (raw or "").strip().lower()
|
||||
for status in ALLOWED_VALIDATION_STATUSES:
|
||||
if text == status.lower():
|
||||
return status
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def _baseline_proof_complete(text: str, baseline_proof: dict | None) -> bool:
|
||||
proof = baseline_proof or {}
|
||||
worktree = (
|
||||
(proof.get("worktree_path") or "").strip()
|
||||
or _first_match(_BASELINE_WORKTREE_USED_RE, text)
|
||||
).lower()
|
||||
if not worktree or worktree in {"none", "n/a", "not used", "not applicable"}:
|
||||
return False
|
||||
if "branches/" not in worktree and not worktree.startswith("branches/"):
|
||||
return False
|
||||
target_sha = (proof.get("baseline_target_sha") or "").strip()
|
||||
if not target_sha:
|
||||
target_sha = _first_match(_BASELINE_TARGET_SHA_RE, text)
|
||||
if not _FULL_SHA.match(target_sha or ""):
|
||||
return False
|
||||
if proof.get("failure_signatures_match") is True:
|
||||
return True
|
||||
if _FAILURE_SIGNATURE_RE.search(text) and _BASELINE_FAILURES_RE.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _merge_simulation_passed(text: str, command_log: list | None) -> bool:
|
||||
merge_result = _first_match(_MERGE_SIM_RESULT_RE, text).lower()
|
||||
if merge_result in {"passed", "pass", "clean", "succeeded", "success"}:
|
||||
sim = assess_merge_simulation_report(text, command_log=command_log)
|
||||
return sim.get("proven") and not sim.get("block")
|
||||
if "pass" in merge_result and "fail" not in merge_result:
|
||||
sim = assess_merge_simulation_report(text, command_log=command_log)
|
||||
return sim.get("proven") and not sim.get("block")
|
||||
return False
|
||||
|
||||
|
||||
def _raw_head_failed(text: str) -> bool:
|
||||
raw = _first_match(_RAW_HEAD_RESULT_RE, text).lower()
|
||||
if raw in {"failed", "fail", "failure"}:
|
||||
return True
|
||||
if "fail" in raw and "pass" not in raw:
|
||||
return True
|
||||
return bool(re.search(r"\bfailed\b.*pr-head validation", text, re.IGNORECASE))
|
||||
|
||||
|
||||
def _raw_head_passed(text: str) -> bool:
|
||||
raw = _first_match(_RAW_HEAD_RESULT_RE, text).lower()
|
||||
return raw in {"passed", "pass", "success"}
|
||||
|
||||
|
||||
def assess_validation_status_vocabulary(
|
||||
report_text: str,
|
||||
*,
|
||||
command_log: list | None = None,
|
||||
baseline_proof: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Bind validation-status labels to the proof path that actually ran (#406)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
status_raw = _first_match(_STATUS_FIELD_RE, text)
|
||||
status = _normalize_status_label(status_raw) if status_raw else ""
|
||||
|
||||
if status_raw and status not in ALLOWED_VALIDATION_STATUSES:
|
||||
reasons.append(
|
||||
f"unknown validation status {status_raw!r}; use one of "
|
||||
f"{sorted(ALLOWED_VALIDATION_STATUSES)}"
|
||||
)
|
||||
|
||||
if status == STATUS_BASELINE_EQUIVALENT:
|
||||
if not _baseline_proof_complete(text, baseline_proof):
|
||||
reasons.append(
|
||||
"baseline-equivalent failure accepted requires baseline "
|
||||
"worktree path, baseline target SHA, and matching failure "
|
||||
"signatures (#406)"
|
||||
)
|
||||
|
||||
if status == STATUS_MERGE_SIM_RESOLVED:
|
||||
if not _raw_head_failed(text):
|
||||
reasons.append(
|
||||
"raw-head failure resolved by merge simulation requires "
|
||||
"raw PR-head validation result: failed (#406)"
|
||||
)
|
||||
if not _merge_simulation_passed(text, command_log):
|
||||
reasons.append(
|
||||
"raw-head failure resolved by merge simulation requires "
|
||||
"passing merge simulation with worktree/index mutation proof "
|
||||
"(#317/#406)"
|
||||
)
|
||||
|
||||
if status == STATUS_TRANSIENT_PASS:
|
||||
if not _TRANSIENT_HISTORY_RE.search(text):
|
||||
reasons.append(
|
||||
"passed after transient failure investigation requires "
|
||||
"documented earlier validation failure history (#396/#406)"
|
||||
)
|
||||
|
||||
if status == STATUS_PASSED and _raw_head_failed(text):
|
||||
reasons.append(
|
||||
"validation status passed contradicts raw PR-head validation "
|
||||
"failure; use a precise status (#406)"
|
||||
)
|
||||
|
||||
if status == STATUS_BASELINE_EQUIVALENT and _merge_simulation_passed(
|
||||
text, command_log
|
||||
) and not _baseline_proof_complete(text, baseline_proof):
|
||||
reasons.append(
|
||||
"baseline-equivalent failure accepted is misleading when only "
|
||||
"merge simulation resolved the failure; use "
|
||||
"'raw-head failure resolved by merge simulation' (#406)"
|
||||
)
|
||||
|
||||
if status == STATUS_FAILED and _merge_simulation_passed(text, command_log):
|
||||
reasons.append(
|
||||
"validation status failed contradicts passing merge simulation; "
|
||||
"report the precise resolution status (#406)"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"status_claimed": status or None,
|
||||
"raw_status_label": status_raw or None,
|
||||
"raw_head_failed": _raw_head_failed(text),
|
||||
"raw_head_passed": _raw_head_passed(text),
|
||||
"merge_simulation_passed": _merge_simulation_passed(text, command_log),
|
||||
"baseline_proof_complete": _baseline_proof_complete(text, baseline_proof),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"use a validation status that matches the proof path executed "
|
||||
"(baseline worktree, merge simulation, or transient history)"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
+12
-10
@@ -38,13 +38,7 @@ def _queue_table(
|
||||
title: str,
|
||||
items: tuple[QueueItem, ...],
|
||||
columns: tuple[tuple[str, str], ...],
|
||||
fetch_failed: bool = False,
|
||||
) -> str:
|
||||
if fetch_failed:
|
||||
return (
|
||||
f"<h3>{html.escape(title)}</h3>"
|
||||
"<p class='muted'>Not loaded — queue fetch did not complete.</p>"
|
||||
)
|
||||
if not items:
|
||||
return f"<h3>{html.escape(title)}</h3><p class='muted'>No open items.</p>"
|
||||
|
||||
@@ -78,12 +72,22 @@ def render_queue_page(snapshot: QueueSnapshot) -> str:
|
||||
f'<div class="stub"><p><strong>Queue unavailable:</strong> '
|
||||
f"{html.escape(snapshot.fetch_error)}</p></div>"
|
||||
)
|
||||
inventory_block = (
|
||||
"<p class='muted'><strong>Inventory:</strong> not fetched "
|
||||
"(fail closed — queue tables omitted).</p>"
|
||||
)
|
||||
return (
|
||||
"<h2>Live queue</h2>"
|
||||
f"<p class='meta'>Repository: <code>{html.escape(snapshot.repo_label)}</code> "
|
||||
f"· project <code>{html.escape(snapshot.project_id)}</code></p>"
|
||||
f"{error_block}"
|
||||
f"{inventory_block}"
|
||||
"<p class='muted'>Read-only MVP — claims, reviews, and merges stay in Gitea/MCP tools.</p>"
|
||||
)
|
||||
|
||||
fetch_failed = bool(snapshot.fetch_error)
|
||||
pr_section = _queue_table(
|
||||
title="Open pull requests",
|
||||
items=snapshot.prs,
|
||||
fetch_failed=fetch_failed,
|
||||
columns=(
|
||||
("number", "#"),
|
||||
("title", "Title"),
|
||||
@@ -96,7 +100,6 @@ def render_queue_page(snapshot: QueueSnapshot) -> str:
|
||||
issue_section = _queue_table(
|
||||
title="Open issues",
|
||||
items=snapshot.issues,
|
||||
fetch_failed=fetch_failed,
|
||||
columns=(
|
||||
("number", "#"),
|
||||
("title", "Title"),
|
||||
@@ -110,7 +113,6 @@ def render_queue_page(snapshot: QueueSnapshot) -> str:
|
||||
"<h2>Live queue</h2>"
|
||||
f"<p class='meta'>Repository: <code>{html.escape(snapshot.repo_label)}</code> "
|
||||
f"· project <code>{html.escape(snapshot.project_id)}</code></p>"
|
||||
f"{error_block}"
|
||||
f"{_pagination_html('PR', snapshot.pr_pagination)}"
|
||||
f"{pr_section}"
|
||||
f"{_pagination_html('Issue', snapshot.issue_pagination)}"
|
||||
|
||||
Reference in New Issue
Block a user