Compare commits

..
Author SHA1 Message Date
sysadmin eaead549c3 feat(reviewer-preflight): check Workspace mutations handoff fields 2026-07-05 16:43:02 -04:00
sysadmin 99ffc79e21 test(preflight): add preflight order verification tests 2026-07-05 16:40:24 -04:00
sysadmin 15bd70167e feat(preflight): block workspace edits before identity/capability verification 2026-07-05 16:40:21 -04:00
sysadmin 4fb08a012e Merge master into feat/issue-204 2026-07-05 16:29:58 -04:00
sysadmin c6fd0fd963 Merge pull request 'Add fail-closed PR inventory wall when list_prs returns an unexpected empty queue (Issue #194)' (#195) from feat/issue-194-fail-closed-pr-inventory-wall into master 2026-07-05 15:27:08 -05:00
sysadmin 4e8b6cc5a9 Merge pull request 'Add role-boundary proofs for reviewer queue workflows (Issue #175)' (#192) from feat/issue-175-role-pivot-wall into master 2026-07-05 15:24:45 -05:00
sysadmin 88deed4e69 feat(author-workflow): enforce exact issue lock before branch/commit/push/PR (Closes #204) 2026-07-05 16:23:26 -04:00
sysadmin 9a35b80e9a Merge pull request 'Fix MCP registration and naming for Jenkins/GlitchTip servers and add discoverability coverage (#146)' (#190) from feat/issue-150-add-integration-discoverability-coverage into master 2026-07-05 15:18:29 -05:00
sysadmin 2b6a60a189 Merge pull request 'Add fail-closed branch-identity proofs for author commit/push workflow (Issue #177)' (#181) from feat/issue-177-branch-drift-proofs into master 2026-07-05 15:13:24 -05:00
sysadmin 10d2644790 feat(reviewer-workflow): add hard wall against reviewer mutations through alternate profile or CLI side-channel 2026-07-05 16:12:44 -04:00
sysadmin b354c93710 Implement fail-closed PR inventory trust gate for Issue #194 2026-07-05 16:06:17 -04:00
sysadmin 4dcf8fdfe4 Merge pull request 'Require and validate Controller Handoff sections in workflow final reports (Issue #182)' (#186) from feat/issue-182-controller-handoff-enforcement into master 2026-07-05 14:54:07 -05:00
sysadmin 574a9ea7c1 Add role-boundary proofs for reviewer queue workflows 2026-07-05 15:42:35 -04:00
sysadmin 5b1f0be2be feat: fix MCP registration/naming for Jenkins/GlitchTip and add discoverability negative assertions (#146)
- Updated mcp_server.py operator guide entries to use jenkins-mcp / glitchtip-mcp names matching mcp-control-plane.
- Added reload/reconnect instructions for clients in notes.
- Added test for 'enabled but no usable tools' negative assertion.
- Updated EXPECTED_SKILLS and assertions in test_operator_guide.py.

Scoped to issue #146. Author profile.

Refs #146
2026-07-05 15:41:49 -04:00
sysadminandClaude Fable 5 45c5cac2bc Require and validate Controller Handoff sections in workflow final reports
- Upgrade SKILL.md §K compact format to the issue #182 canonical field set
  (Task/Repo/Role/Identity/Issue-PR/Branch-SHA/Files/Validation/Mutations/
  Current status/Blockers/Next/Safety) plus role-specific field lists for
  review/merge, author, and queue/inventory tasks.
- Point the review-pr, merge-pr, and start-issue template handoff lines at
  the exactly-titled Controller Handoff section with their role fields.
- Add review_proofs.assess_controller_handoff(): reports without the exact
  section are 'missing' (downgraded), present-but-partial are 'incomplete'
  with the absent fields listed, and role extras are enforced per role.
- Add TestControllerHandoff (8 tests) including a SKILL.md doc-contract
  test so the documented requirement cannot silently rot.

The handoff supplements the full report; full-report validation rules are
unchanged.

Closes #182

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 15:36:57 -04:00
sysadmin 601c608c58 Merge pull request 'Add repo-name disambiguation and complete-inventory proofs for blind PR review' (#185) from feat/issue-184-repo-name-disambiguation into master 2026-07-05 14:32:34 -05:00
sysadmin e2bccbafee feat(review-workflow): add repo-name disambiguation and complete-inventory proofs (#184) 2026-07-05 15:30:06 -04:00
sysadmin 340b585bfd Merge pull request 'test: isolate CLI output capture with monkeypatch/redirect to fix stdout corruption in full suite (Issue #178)' (#180) from feat/issue-178-fix-test-suite-stdout-capture into master 2026-07-05 14:26:31 -05:00
sysadmin ff4ab500df test: isolate CLI output capture with monkeypatch/redirect to fix stdout corruption in full suite runs (Issue #178)
- Replace raw patch("sys.stdout") and contextlib.redirect_stderr with pytest MonkeyPatch or contextlib redirect scoped to main calls in CLI tests (create_*, prs, python_cli, manage_labels, merge_pr, review_pr).
- Add regression tests in test_review_proofs.py for stdout/stderr remaining usable.
- Ensures normal pytest summary output without junitxml workaround; improves review validation reporting per #173.
- No behavior change to production code or gates.

Refs #178
2026-07-05 15:16:55 -04:00
sysadminandClaude Fable 5 941ada38c2 feat(author-workflow): add fail-closed branch-identity proofs for commit/push (#177)
Adds author_proofs.py, the author-side counterpart of review_proofs.py
(#173), turning the branch-drift incident from PR #176 into fail-closed
gates:

- verify_branch_for_commit: the current branch must equal the intended
  feature branch and may never be a protected branch (master, main,
  develop, development, dev); missing state fails closed.
- detect_branch_drift: any branch or HEAD change between validation time
  and commit time — including an external branch switch in a shared
  worktree, which is treated as an expected event to detect — blocks the
  commit until reconciled.
- verify_push_target: a push requires the local branch, remote target
  branch, and intended issue branch to all match, none protected.
- assess_protected_branch_commit: an accidental protected-branch commit
  must never be pushed, requires repair, and the repair must be reported;
  pushing the accident or silently continuing is a violation.
- build_commit_push_report: final-report block carrying the branch proof
  before commit and before push; any failed proof, drift, or violation
  makes the status blocked.

tests/test_author_proofs.py (27 tests) covers the issue's harness
assertions: commit on master blocked; drift between validation and commit
blocked; push target mismatch blocked; shared-worktree branch switch
detected; repair path cannot silently continue without reporting.

SKILL.md section E and templates/start-issue.md now require recording the
validation-time branch/HEAD, the branch proof before commit, the branch
proof before push, and non-silent accident repair. No review/merge/
permission gate is weakened.

Closes #177

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 15:16:36 -04:00
21 changed files with 2015 additions and 78 deletions
+217
View File
@@ -0,0 +1,217 @@
"""Fail-closed branch-identity proofs for author workflows (#177).
Author-side counterpart of the reviewer proofs in ``review_proofs.py``
(#173). During the #173 implementation itself, a commit landed on local
``master`` because the shared checkout's branch moved mid-session (origin
incident of #177). These helpers turn that from an after-the-fact repair
into a fail-closed gate: an author workflow must prove its local git state
before staging, committing, or pushing.
The helpers are pure (no git calls): the workflow gathers the raw facts
(``git branch --show-current``, ``git rev-parse HEAD``, the push refspec,
the branch named in the issue claim) and passes them in, so the same logic
works from prompts, harness assertions, and tests. Shared-worktree branch
switches by other sessions are treated as expected events to detect, not
exceptional ones. Nothing here weakens the review/merge/permission gates.
"""
PROTECTED_BRANCHES = frozenset(
{"master", "main", "develop", "development", "dev"}
)
def _clean(name):
return (name or "").strip()
def verify_branch_for_commit(current_branch, intended_branch):
"""Required behavior 1: prove the branch before staging/committing.
Proven only when both names are present, the intended branch is not a
protected branch, and the current branch equals the intended one (which
also rules out being on any protected branch). Returns {'proven',
'block', 'reasons', 'current_branch', 'intended_branch'}.
"""
reasons = []
current = _clean(current_branch)
intended = _clean(intended_branch)
if not current:
reasons.append(
"current branch unknown (detached HEAD or state not read); "
"fail closed"
)
if not intended:
reasons.append("intended feature branch not stated; fail closed")
if intended and intended in PROTECTED_BRANCHES:
reasons.append(
f"intended branch '{intended}' is a protected branch; author "
"work must target a feature branch"
)
if current and current in PROTECTED_BRANCHES:
reasons.append(
f"current branch '{current}' is a protected branch; committing "
"here is blocked"
)
if current and intended and current != intended:
reasons.append(
f"current branch '{current}' is not the intended feature branch "
f"'{intended}'; stop before staging/committing"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"current_branch": current or None,
"intended_branch": intended or None,
}
def detect_branch_drift(branch_at_validation, head_at_validation,
current_branch, current_head):
"""Required behaviors 23: stop when branch or HEAD moved mid-session.
Compares the branch name and HEAD SHA captured at validation time with
the state observed immediately before commit/push. Any difference —
including an external branch switch in a shared worktree — is drift and
blocks until reconciled. Missing state fails closed.
"""
reasons = []
branch_then = _clean(branch_at_validation)
branch_now = _clean(current_branch)
head_then = _clean(head_at_validation).lower()
head_now = _clean(current_head).lower()
if not branch_then or not head_then:
reasons.append("validation-time branch/HEAD not recorded; fail closed")
if not branch_now or not head_now:
reasons.append("current branch/HEAD not read; fail closed")
if branch_then and branch_now and branch_then != branch_now:
reasons.append(
f"branch changed from '{branch_then}' to '{branch_now}' since "
"validation — possible external branch switch in a shared "
"worktree; stop and reconcile before committing"
)
if head_then and head_now and head_then != head_now:
reasons.append(
"HEAD moved since validation; re-validate on the current HEAD "
"before committing"
)
drifted = bool(reasons)
return {"drifted": drifted, "block": drifted, "reasons": reasons}
def verify_push_target(current_branch, remote_target_branch, intended_branch):
"""Acceptance: a push needs local, remote, and intended branches to match.
Proven only when all three names are present, equal, and not a
protected branch — a feature-branch workflow never pushes a protected
branch, and never pushes to a refspec other than its own branch.
"""
reasons = []
current = _clean(current_branch)
remote_target = _clean(remote_target_branch)
intended = _clean(intended_branch)
if not current:
reasons.append("current branch unknown; fail closed")
if not remote_target:
reasons.append("remote target branch not stated; fail closed")
if not intended:
reasons.append("intended feature branch not stated; fail closed")
for label, name in (("current", current), ("remote target", remote_target),
("intended", intended)):
if name and name in PROTECTED_BRANCHES:
reasons.append(
f"{label} branch '{name}' is a protected branch; author "
"pushes to protected branches are blocked"
)
if current and remote_target and current != remote_target:
reasons.append(
f"push target '{remote_target}' does not match the local branch "
f"'{current}'"
)
if current and intended and current != intended:
reasons.append(
f"local branch '{current}' does not match the intended feature "
f"branch '{intended}'"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
}
def assess_protected_branch_commit(commit_branch, pushed=False,
repair_reported=True):
"""Required behavior 4: handle an accidental protected-branch commit.
If a commit landed on a protected branch: it must never be pushed, a
repair is required, and the repair must be *reported* — silently
continuing after (or without) repair is a violation, as is having
pushed the accident.
"""
branch = _clean(commit_branch)
accident = branch in PROTECTED_BRANCHES
violations = []
if accident:
if pushed:
violations.append(
f"accidental commit on protected branch '{branch}' was "
"pushed; protected-branch pushes are forbidden"
)
if not repair_reported:
violations.append(
"protected-branch commit repair was not reported; the "
"workflow must surface the accident and the repair steps, "
"never silently continue"
)
return {
"accident": accident,
"must_not_push": accident,
"repair_required": accident,
"violations": violations,
}
def build_commit_push_report(commit_proof, drift, push_proof, accident=None):
"""Acceptance: final report carries branch proof before commit and push.
Combines the individual proofs; any failed proof, detected drift, or
accident violation makes the status 'blocked' — the workflow stops and
reports instead of continuing.
"""
accident = accident or {"accident": False, "violations": []}
violations = list(accident.get("violations", []))
blocked = (
not commit_proof.get("proven")
or drift.get("drifted")
or not push_proof.get("proven")
or bool(violations)
)
return {
"status": "blocked" if blocked else "ok",
"branch_proof_before_commit": bool(commit_proof.get("proven")),
"branch_proof_before_push": bool(push_proof.get("proven")),
"drift_detected": bool(drift.get("drifted")),
"protected_branch_accident": bool(accident.get("accident")),
"violations": violations,
"reasons": (
list(commit_proof.get("reasons", []))
+ list(drift.get("reasons", []))
+ list(push_proof.get("reasons", []))
),
}
+313 -6
View File
@@ -16,10 +16,70 @@ Configuration (mcp_config.json):
import os
import re
import sys
import json
import functools
import contextlib
import subprocess
LOCK_FILE = "/tmp/gitea_mutation_authority.lock"
ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"
def record_mutation_authority(profile_name: str | None, identity: str | None, remote: str | None, task: str | None):
"""Record the resolved capability context to fail-closed lock file."""
data = {
"initial_profile": profile_name,
"initial_identity": identity,
"current_profile": profile_name,
"current_identity": identity,
"remote": remote,
"task": task,
"role_pivot_authorized": False,
"role_pivot_record": None,
}
try:
with open(LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception:
pass
def verify_mutation_authority(remote: str | None, host: str | None = None, required_role: str = "reviewer"):
"""Verify that the current mutation matches the locked capability context."""
if not os.path.exists(LOCK_FILE):
raise RuntimeError("Mutation authority lock is missing (fail closed)")
try:
with open(LOCK_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
raise RuntimeError(f"Could not read mutation authority lock: {e} (fail closed)")
if data.get("remote") != remote:
raise RuntimeError(
f"Mutation remote '{remote}' does not match locked remote '{data.get('remote')}' (fail closed)"
)
profile = get_profile()
active_profile = profile.get("profile_name")
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
active_identity = _authenticated_username(h) if h else None
locked_profile = data.get("current_profile")
locked_identity = data.get("current_identity")
if active_profile != locked_profile or active_identity != locked_identity:
raise RuntimeError(
f"Mutation profile '{active_profile}' or identity '{active_identity}' "
f"does not match locked authority (profile: '{locked_profile}', identity: '{locked_identity}') (fail closed)"
)
# Check reviewer/author role pivot boundaries
if required_role == "reviewer" and "author" in str(data.get("initial_profile")).lower() and "reviewer" in str(active_profile).lower():
if not data.get("role_pivot_authorized"):
raise RuntimeError(
"Attempted reviewer mutation from author session without authorized role pivot (fail closed)"
)
# Resolve the project root. MCP clients must launch this script directly with
# the venv interpreter (venv/bin/python3) — see the config example above. We do
# NOT os.execv() to re-point the interpreter: replacing the process after the
@@ -31,6 +91,100 @@ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
PREFLIGHT_FILE = "/tmp/gitea_preflight_check.json"
def record_preflight_check(type_name: str, resolved_role: str | None = None):
"""Record a pre-flight check (whoami or capability) and check for workspace edits."""
import time
is_dirty = False
in_test = "pytest" in sys.modules or "unittest" in sys.modules
if in_test and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
is_dirty = False
else:
try:
res = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, cwd=PROJECT_ROOT
)
for line in res.stdout.splitlines():
if line and not line.startswith("??"):
is_dirty = True
break
except Exception:
pass
data = {}
if os.path.exists(PREFLIGHT_FILE):
try:
with open(PREFLIGHT_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
pass
if is_dirty:
if type_name == "whoami" and not data.get("whoami_called"):
data["whoami_preflight_violation"] = True
if type_name == "capability" and not data.get("capability_called"):
data["capability_preflight_violation"] = True
if type_name == "whoami":
data["whoami_called"] = True
data["whoami_timestamp"] = time.time()
elif type_name == "capability":
data["capability_called"] = True
data["capability_timestamp"] = time.time()
if resolved_role:
data["role"] = resolved_role
try:
with open(PREFLIGHT_FILE, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception:
pass
def verify_preflight_purity(remote: str | None = None):
"""Verify that identity and capability were verified prior to edits, and that reviewers made no edits."""
in_test = "pytest" in sys.modules or "unittest" in sys.modules
if in_test and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
return
if not os.path.exists(PREFLIGHT_FILE):
raise RuntimeError("Pre-flight order violation: Identity and capability verification were skipped (fail closed)")
try:
with open(PREFLIGHT_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
raise RuntimeError(f"Could not read pre-flight check record: {e} (fail closed)")
if not data.get("whoami_called"):
raise RuntimeError("Pre-flight order violation: Identity (gitea_whoami) has not been verified (fail closed)")
if not data.get("capability_called"):
raise RuntimeError("Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)")
if data.get("whoami_preflight_violation"):
raise RuntimeError("Pre-flight order violation: Workspace file edits occurred before gitea_whoami verification (fail closed)")
if data.get("capability_preflight_violation"):
raise RuntimeError("Pre-flight order violation: Workspace file edits occurred before gitea_resolve_task_capability verification (fail closed)")
is_dirty = False
if os.environ.get("GITEA_TEST_ENVIRONMENT") == "1" and not os.environ.get("GITEA_TEST_FORCE_DIRTY"):
is_dirty = False
else:
try:
res = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, cwd=PROJECT_ROOT
)
for line in res.stdout.splitlines():
if line and not line.startswith("??"):
is_dirty = True
break
except Exception:
pass
if data.get("role") == "reviewer" and is_dirty:
raise RuntimeError("Reviewer role violation: Reviewer profile is forbidden from modifying tracked workspace files (fail closed)")
from mcp.server.fastmcp import FastMCP # noqa: E402
from gitea_auth import ( # noqa: E402
@@ -325,6 +479,7 @@ def gitea_create_issue(
dict with 'number' of the created issue ('url' only with the reveal opt-in).
"""
h, o, r = _resolve(remote, host, org, repo)
verify_preflight_purity(remote)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues"
try:
@@ -340,6 +495,84 @@ def gitea_create_issue(
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
@mcp.tool()
def gitea_lock_issue(
issue_number: int,
branch_name: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Lock exactly one Gitea issue and its branch name to ensure durable tracking.
Args:
issue_number: The tracking issue number.
branch_name: The branch name (must match (fix|feat|docs|chore)/issue-<issue_number>-<desc>).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override Gitea host.
org: Override Org.
repo: Override Repo.
"""
# 1. Enforce branch name includes issue number
expected_pattern = f"issue-{issue_number}"
if expected_pattern not in branch_name:
raise ValueError(
f"Branch name '{branch_name}' must contain locked issue pattern '{expected_pattern}' (fail closed)"
)
# 2. Check if the issue already has an open PR (reuse protection)
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
try:
prs = api_get_all(url, auth)
except Exception as e:
raise RuntimeError(f"Could not list open PRs to verify issue lock: {e}")
for pr in prs:
pr_head = pr.get("head", {}).get("ref", "")
pr_title = pr.get("title", "")
pr_body = pr.get("body", "")
if expected_pattern in pr_head:
raise ValueError(
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}, branch '{pr_head}') (fail closed)"
)
patterns = [
f"closes #{issue_number}",
f"fixes #{issue_number}",
]
text_to_check = f"{pr_title} {pr_body}".lower()
if any(p in text_to_check for p in patterns):
raise ValueError(
f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (fail closed)"
)
data = {
"issue_number": issue_number,
"branch_name": branch_name,
"remote": remote,
"org": o,
"repo": r,
}
try:
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception as e:
raise RuntimeError(f"Could not write issue lock file: {e}")
return {
"success": True,
"message": f"Successfully locked issue #{issue_number} to branch '{branch_name}' (fail-closed check complete).",
"issue_number": issue_number,
"branch_name": branch_name,
}
@mcp.tool()
def gitea_create_pr(
title: str,
@@ -367,6 +600,42 @@ def gitea_create_pr(
dict with 'number' of the created PR ('url' only with the reveal opt-in).
"""
h, o, r = _resolve(remote, host, org, repo)
verify_preflight_purity(remote)
# ── Issue Lock Validation (Issue #194 / #196) ──
if not os.path.exists(ISSUE_LOCK_FILE):
raise RuntimeError("Issue lock is missing (fail closed). Call gitea_lock_issue first.")
try:
with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
except Exception as e:
raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)")
locked_issue = lock_data.get("issue_number")
locked_branch = lock_data.get("branch_name")
if head != locked_branch:
raise ValueError(
f"PR head branch '{head}' does not match locked branch '{locked_branch}' (fail closed)"
)
# Check for forbidden terms anywhere in title/body
forbidden_terms = ["equivalent", "related", "same as"]
text_to_check = f"{title} {body}".lower()
for term in forbidden_terms:
if term in text_to_check:
raise ValueError(
f"PR title or body contains forbidden term '{term}' (fail closed)"
)
# Ensure Closes #<locked_issue> or Fixes #<locked_issue> is present exactly
closes_pattern = re.compile(rf"\b(closes|fixes)\s+#{locked_issue}\b", re.IGNORECASE)
if not closes_pattern.search(text_to_check):
raise ValueError(
f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls"
payload = {"title": title, "body": body, "head": head, "base": base}
@@ -976,6 +1245,7 @@ def gitea_submit_pr_review(
authenticated user, profile name, PR author, PR number, head SHA
checked, and the reasons/gates passed or blocked. Never secrets.
"""
verify_preflight_purity(remote)
action = (action or "").strip().lower()
result = {
"requested_action": action,
@@ -991,6 +1261,12 @@ def gitea_submit_pr_review(
}
reasons = result["reasons"]
try:
verify_mutation_authority(remote, host, required_role="reviewer")
except RuntimeError as e:
reasons.append(str(e))
return result
# Gate 1 — valid review action (no mutation on unknown action).
if action not in _REVIEW_ACTIONS:
reasons.append(
@@ -1200,6 +1476,7 @@ def gitea_commit_files(
dict with success status and commit/branch information.
"""
h, o, r = _resolve(remote, host, org, repo)
verify_preflight_purity(remote)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/contents"
@@ -1293,6 +1570,7 @@ def gitea_merge_pr(
reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets.
"""
verify_preflight_purity(remote)
do = (do or "").strip().lower()
result = {
"performed": False,
@@ -1311,6 +1589,12 @@ def gitea_merge_pr(
}
reasons = result["reasons"]
try:
verify_mutation_authority(remote, host, required_role="reviewer")
except RuntimeError as e:
reasons.append(str(e))
return result
# Gate 1 — valid merge method (no API call on a bad method).
if do not in _MERGE_METHODS:
reasons.append(
@@ -2307,26 +2591,26 @@ _PROJECT_SKILLS = {
"committed.",
],
},
"jenkins-readonly": {
"jenkins-mcp": {
"description": "Read-only Jenkins CI inspection (jobs, builds, "
"logs). Actual server name: jenkins-mcp (see mcp-control-plane).",
"logs).",
"when_to_use": "Checking CI state once Jenkins MCP tools exist.",
"required_operations": ["jenkins.read"],
"status": "designed-not-implemented",
"notes": "Server code exists in mcp-control-plane as jenkins-mcp (read tools + gated trigger); registration pending (#55); docs in Gitea-Tools use historical name. Report SKIPPED if not connected. Do not substitute shell/API. Trigger requires dedicated profile (see #56).",
"notes": "Server code exists in mcp-control-plane as jenkins-mcp (read tools + gated trigger); registration pending. To register for discoverability in clients (Codex/Gemini/Grok/etc.): add to client MCP config under the jenkins-mcp name, reconnect/reload the client session after registration. Report SKIPPED if not connected. Do not substitute shell/API. Trigger requires dedicated profile (see #56).",
"steps": [
"Confirm a Jenkins MCP server is connected (jenkins-mcp); if not, report "
"SKIPPED.",
"Use read-only operations only; never trigger unless using dedicated profile + confirmation.",
],
},
"glitchtip-readonly": {
"description": "Read-only GlitchTip error/event inspection. Actual server name: glitchtip-mcp (see mcp-control-plane).",
"glitchtip-mcp": {
"description": "Read-only GlitchTip error/event inspection.",
"when_to_use": "Investigating reported errors once GlitchTip MCP "
"tools exist.",
"required_operations": ["glitchtip.read"],
"status": "designed-not-implemented",
"notes": "Server code exists in mcp-control-plane as glitchtip-mcp (read-only tools); registration pending (#55); filing orchestrator is partial in mcp-control-plane (see #57). Report SKIPPED if not connected. Filing to Gitea is separate orchestrator, not in this server.",
"notes": "Server code exists in mcp-control-plane as glitchtip-mcp (read-only tools); registration pending. To register for discoverability in clients (Codex/Gemini/Grok/etc.): add to client MCP config under the glitchtip-mcp name, reconnect/reload the client session after registration. Filing orchestrator is partial in mcp-control-plane (see #57). Report SKIPPED if not connected. Filing to Gitea is separate orchestrator, not in this server.",
"steps": [
"Confirm a GlitchTip MCP server is connected (glitchtip-mcp); if not, report "
"SKIPPED.",
@@ -2559,6 +2843,7 @@ def gitea_whoami(
"""
if remote not in REMOTES:
raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}")
record_preflight_check("whoami")
h = host or REMOTES[remote]["host"]
auth = _auth(h)
url = gitea_url(h, "/api/v1/user")
@@ -2987,6 +3272,25 @@ def gitea_activate_profile(
after_profile = get_profile()["profile_name"]
after_identity = _authenticated_username(h) if h else None
# 4.5 Record pivot in mutation authority lock
if os.path.exists(LOCK_FILE):
try:
with open(LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
lock_data["current_profile"] = after_profile
lock_data["current_identity"] = after_identity
lock_data["role_pivot_authorized"] = True
lock_data["role_pivot_record"] = {
"from_profile": before_profile,
"to_profile": after_profile,
"from_identity": before_identity,
"to_identity": after_identity
}
with open(LOCK_FILE, "w", encoding="utf-8") as f:
json.dump(lock_data, f)
except Exception:
pass
# 5. Audit the switch if auditing is on
_audit(
"activate_profile",
@@ -3376,6 +3680,7 @@ def gitea_resolve_task_capability(
required_permission = TASK_MAP[task]["permission"]
required_role = TASK_MAP[task]["role"]
record_preflight_check("capability", required_role)
profile = get_profile()
config = gitea_config.load_config()
@@ -3476,6 +3781,8 @@ def gitea_resolve_task_capability(
"STOP: the active profile cannot perform the requested task; "
"follow exact_safe_next_action instead of improvising.")
record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task)
return {
"requested_task": task,
"required_operation_permission": required_permission,
+24 -1
View File
@@ -24,7 +24,7 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
if os.path.exists(venv_python) and sys.executable != venv_python:
os.execv(venv_python, [venv_python] + sys.argv)
from gitea_auth import get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url
from gitea_auth import get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url, get_profile
def main(argv=None):
@@ -60,6 +60,29 @@ def main(argv=None):
host, org, repo = resolve_remote(args)
# ── Mutation Authority context wall check (Issue #194) ──
LOCK_FILE = "/tmp/gitea_mutation_authority.lock"
if os.path.exists(LOCK_FILE):
try:
with open(LOCK_FILE, "r", encoding="utf-8") as f:
lock_data = json.load(f)
# Resolve current CLI profile
cli_profile = get_profile().get("profile_name")
locked_profile = lock_data.get("current_profile")
if cli_profile != locked_profile:
print(
f"Mismatched active profile vs mutation profile (CLI override rejected): "
f"CLI profile '{cli_profile}' does not match locked active profile '{locked_profile}' (fail closed)",
file=sys.stderr
)
return 3
except Exception as e:
print(f"Mutation authority check failed: {e}", file=sys.stderr)
return 3
body = args.body
if args.body_file:
if args.body_file == "-":
+286 -3
View File
@@ -18,6 +18,54 @@ import re
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
# Repo name disambiguation rules for blind PR queue review.
# User phrases referencing the "MCP Gitea tool" (or similar) must resolve to
# Gitea-Tools repo, not be confused with mcp-control-plane.
# If ambiguous (e.g. just "open PRs"), check both configured repos.
REPO_ALIASES = {
"gitea-tools": "Scaled-Tech-Consulting/Gitea-Tools",
"gitea tool": "Scaled-Tech-Consulting/Gitea-Tools",
"mcp gitea tool": "Scaled-Tech-Consulting/Gitea-Tools",
"gitea mcp tool": "Scaled-Tech-Consulting/Gitea-Tools",
"gitea-tools repo": "Scaled-Tech-Consulting/Gitea-Tools",
"mcp-control-plane": "Scaled-Tech-Consulting/mcp-control-plane",
"mcp control plane": "Scaled-Tech-Consulting/mcp-control-plane",
}
def resolve_repos_from_user_reference(
reference: str, configured: list[str] | None = None
) -> list[str]:
"""Resolve a user reference string to the list of target repos to inventory.
- Exact aliases for Gitea-Tools map only to Gitea-Tools.
- mcp-control-plane aliases map only to it.
- Empty, ambiguous, or general "open PRs" default to all configured repos
(both by default).
- Returns subset of configured; never invents new repos.
"""
if configured is None:
configured = [
"Scaled-Tech-Consulting/Gitea-Tools",
"Scaled-Tech-Consulting/mcp-control-plane",
]
if not reference or not reference.strip():
return list(configured)
ref_lower = reference.lower()
matched = []
for alias, full_name in REPO_ALIASES.items():
if alias in ref_lower:
if full_name not in matched:
matched.append(full_name)
if matched:
# return only the matched ones that are in configured, preserving order
return [r for r in configured if r in matched]
# no specific alias match → check all (complete inventory required)
return list(configured)
SAFE_NEXT_ACTION_UNKNOWN_CONTAMINATION = (
"evidence missing: report contamination as unknown and "
"choose another PR or stop"
@@ -282,9 +330,97 @@ def assess_self_review_contamination(reviewer_identity, pr_author,
}
def assess_role_boundary(proof):
"""Assess reviewer/author role separation for blind queue workflows.
Issue #175 blocks a reviewer queue task from silently becoming author
implementation work. The workflow may use both namespaces only when that
mixed use is explicit, justified, and non-mutating; author mutations after
a reviewer queue task require an explicit operator authorization.
*proof* keys:
``task_role`` ('reviewer' or 'author'), ``task_kind`` (for example
'blind_pr_queue_review'), ``reviewer_namespace_used``,
``author_namespace_used``, ``author_mutations`` (list), ``review_mutations``
(list), ``operator_authorized_author_work``, ``mixed_namespace_justification``,
``scratch_evidence_claimed``, and ``scratch_evidence_durable``.
"""
proof = proof or {}
task_role = (proof.get("task_role") or "").strip().lower()
task_kind = (proof.get("task_kind") or "").strip().lower()
author_mutations = list(proof.get("author_mutations") or [])
review_mutations = list(proof.get("review_mutations") or [])
reviewer_used = bool(proof.get("reviewer_namespace_used"))
author_used = bool(proof.get("author_namespace_used"))
authorized = bool(proof.get("operator_authorized_author_work"))
mixed_justification = (
proof.get("mixed_namespace_justification") or ""
).strip()
scratch_claimed = bool(proof.get("scratch_evidence_claimed"))
scratch_durable = bool(proof.get("scratch_evidence_durable"))
reasons = []
violations = []
if task_role not in {"reviewer", "author"}:
reasons.append("task role missing or unknown; role boundary unproven")
if task_role == "reviewer":
if author_mutations and not authorized:
violations.append(
"reviewer task performed author mutations without explicit "
"operator authorization"
)
if author_used and not mixed_justification:
reasons.append(
"reviewer task used author namespace without an explicit "
"justification"
)
if task_kind == "blind_pr_queue_review" and author_mutations:
if not authorized:
violations.append(
"blind PR queue review silently pivoted into author "
"implementation"
)
elif task_role == "author":
if review_mutations:
violations.append(
"author task performed reviewer-only mutations"
)
if reviewer_used and author_used and not mixed_justification:
reasons.append(
"mixed reviewer+author namespace use was not reported as a "
"role-boundary event"
)
if scratch_claimed and not scratch_durable:
reasons.append(
"scratch-only notes were claimed as durable evidence"
)
if violations:
status = "violation"
safe_next_action = "stop; report role-boundary violation"
elif reasons:
status = "warning"
safe_next_action = "downgrade final report; do not claim A-level proof"
else:
status = "clean"
safe_next_action = "proceed"
return {
"status": status,
"clean": status == "clean",
"reasons": reasons,
"violations": violations,
"safe_next_action": safe_next_action,
}
def build_final_report(checkout_proof, inventory, validation, contamination,
identity_eligible, merge_performed,
issue_status_verified):
issue_status_verified, role_boundary=None):
"""Required behavior 6 + acceptance criteria: one report, distinct proofs.
Combines the individual proof verdicts into the final-report fields the
@@ -300,6 +436,12 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
checkout_proven = bool(checkout_proof.get("proven"))
validation_claimable = bool(validation.get("claimable"))
validation_strong = validation.get("verdict") == "strong"
role_boundary = role_boundary or {
"status": "warning",
"reasons": ["role-boundary proof missing"],
"violations": [],
}
role_status = role_boundary.get("status", "warning")
downgrade_reasons = []
if not identity_eligible:
@@ -319,6 +461,9 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
downgrade_reasons.append(
f"session contamination status is '{contamination_status}'"
)
if role_status != "clean":
downgrade_reasons.append(f"role boundary status is '{role_status}'")
downgrade_reasons.extend(role_boundary.get("reasons", []))
if not issue_status_verified:
downgrade_reasons.append("linked issue status not verified")
@@ -326,6 +471,7 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
identity_eligible
and checkout_proven
and contamination_status == "clean"
and role_status == "clean"
and validation_claimable
and validation.get("verdict") != "invalid"
)
@@ -336,6 +482,7 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
"merge was performed/claimed although the proofs did not allow "
"one; this run is blocked, not graded"
)
violations.extend(role_boundary.get("violations", []))
if violations:
grade = "blocked"
@@ -352,6 +499,7 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
"pr_author_distinct_from_reviewer":
contamination_status in ("clean",),
"session_contamination": contamination_status,
"role_boundary": role_status,
"inventory_complete": bool(inventory.get("complete")),
"validated_on_pinned_head": checkout_proven and validation_claimable,
"validation_passed":
@@ -382,6 +530,7 @@ HANDOFF_BASE_FIELDS = (
("Files changed", ("files changed", "changed", "files")),
("Validation", ("validation",)),
("Mutations", ("mutations",)),
("Workspace mutations", ("workspace mutations",)),
("Current status", ("current status", "status")),
("Blockers", ("blockers",)),
("Next", ("next",)),
@@ -432,7 +581,7 @@ def _handoff_section_lines(report_text):
return lines[start:]
def assess_controller_handoff(report_text, role=None):
def assess_controller_handoff(report_text, role=None, local_edits=False):
"""Issue #182: final reports without a Controller Handoff downgrade.
Verdicts:
@@ -444,6 +593,7 @@ def assess_controller_handoff(report_text, role=None):
The handoff supplements the full report; this helper never validates
the full report body, only the continuation summary.
"""
import re
section = _handoff_section_lines(report_text)
if section is None:
return {
@@ -457,10 +607,14 @@ def assess_controller_handoff(report_text, role=None):
}
labels = []
fields_dict = {}
for line in section:
stripped = line.strip().lstrip("-*").strip()
if ":" in stripped:
labels.append(stripped.split(":", 1)[0].strip().lower())
k, v = stripped.split(":", 1)
label = k.strip().lower()
labels.append(label)
fields_dict[label] = v.strip()
required = list(HANDOFF_BASE_FIELDS)
required.extend(HANDOFF_ROLE_FIELDS.get(role or "", ()))
@@ -479,9 +633,138 @@ def assess_controller_handoff(report_text, role=None):
"reasons": [f"handoff missing required field: {m}"
for m in missing],
}
# Validate issue/PR references for exact number and no forbidden terms (Issue #194 / #196)
for alias in ("selected issue", "pr number opened", "pr opened", "pr number", "selected pr"):
val = fields_dict.get(alias)
if val:
numbers = re.findall(r"\d+", val)
has_forbidden = any(term in val.lower() for term in ("equivalent", "related", "same", "/"))
if len(numbers) != 1 or has_forbidden:
field_name = "Selected issue/PR"
for name, aliases_list in required:
if alias in aliases_list:
field_name = name
break
return {
"verdict": "incomplete",
"downgraded": True,
"missing_fields": [field_name],
"reasons": [
f"{field_name} must specify exactly one number and no ambiguous references (got: '{val}')"
],
}
if local_edits:
workspace_mutations_val = fields_dict.get("workspace mutations", "").strip().lower()
if not workspace_mutations_val or workspace_mutations_val == "none":
return {
"verdict": "incomplete",
"downgraded": True,
"missing_fields": ["Workspace mutations"],
"reasons": [
"Workspace mutations cannot be 'none' or empty when local edits exist"
],
}
return {
"verdict": "complete",
"downgraded": False,
"missing_fields": [],
"reasons": [],
}
# ── PR Inventory Trust Gate (Issue #194) ──────────────────────────────────────
#
# A reviewer agent may not convert an empty PR list response into a definitive
# "no open PRs" conclusion unless the inventory result is independently proven
# trustworthy.
def pr_inventory_trust_gate(
list_prs_response: list | None,
remote: str | None = None,
org: str | None = None,
repo: str | None = None,
state: str | None = None,
authenticated_profile: dict | None = None,
local_remote_url: str | None = None,
user_context: str | None = None,
corroboration_open_pr_counter: int | None = None,
has_finality_metadata: bool = False,
) -> dict:
"""Evaluate whether an empty PR list is trusted or untrusted.
Returns a dict with 'status', 'reasons', and 'corroborated'.
"""
if list_prs_response is None or not isinstance(list_prs_response, list):
return {
"status": "inventory_error",
"reasons": ["PR list response is invalid (not a list or None)"],
"corroborated": False,
}
if len(list_prs_response) > 0:
return {
"status": "trusted_nonempty",
"reasons": [],
"corroborated": False,
}
reasons = []
# 1. Exact remote, owner, repo, and state filter resolved correctly
if not remote or remote not in ("dadeschools", "prgs"):
reasons.append("remote instance is invalid or unresolved")
if not org or not org.strip():
reasons.append("owner/org is invalid or unresolved")
if not repo or not repo.strip():
reasons.append("repository name is invalid or unresolved")
if state != "open":
reasons.append("state filter is not 'open'")
# 2. Authenticated profile permission check
if not authenticated_profile or not isinstance(authenticated_profile, dict):
reasons.append("authenticated profile is missing or invalid")
else:
allowed = authenticated_profile.get("allowed_operations") or []
if "gitea.read" not in allowed and "read" not in allowed:
reasons.append("authenticated profile lacks read permissions")
# 3. Pagination/finality metadata or independent read path corroboration
corroborated = False
if has_finality_metadata:
corroborated = True
elif corroboration_open_pr_counter == 0:
corroborated = True
else:
reasons.append("pagination finality not proven and open_pr_counter corroboration is missing or non-zero")
# 4. Local checkout remote URL matching the target repo
if not local_remote_url or not isinstance(local_remote_url, str):
reasons.append("local checkout remote URL is missing or invalid")
else:
expected = f"{org}/{repo}".lower()
if expected not in local_remote_url.lower():
reasons.append(f"local remote URL does not match target repository '{org}/{repo}'")
# 5. User context check (indicators that PRs should exist)
if user_context and isinstance(user_context, str):
indicators = ["pr #", "pull request #", "open pr", "pr queue"]
found = [ind for ind in indicators if ind in user_context.lower()]
if found:
reasons.append(f"user context indicates open PRs should exist (matched: {', '.join(found)})")
if reasons:
return {
"status": "untrusted_empty",
"reasons": reasons,
"corroborated": corroborated,
}
return {
"status": "trusted_empty",
"reasons": [],
"corroborated": corroborated,
}
+10
View File
@@ -40,6 +40,16 @@ start_ref="${2:-prgs/master}"
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
if [[ "$allow_unlinked" -eq 0 ]]; then
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
fi
if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \
|| [[ "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then
:
+57 -18
View File
@@ -150,12 +150,33 @@ Worktree folder = branch with `/` replaced by `-`
6. Implement the narrow scope only — no unrelated refactors or formatting churn.
7. Add/update focused tests when behavior changes.
8. Run the checks (tests, compile/lint, `git diff --check`, secret scan).
9. Commit with an issue-linked message.
10. Push the branch.
11. Open a PR to `master`.
12. **If you are the author, stop before review/merge.**
13. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
14. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
Record the branch name and `HEAD` SHA at validation time — the drift
check in step 9 compares against exactly this state.
9. **Branch proof before commit (#177):** prove and state, immediately
before staging/committing (`author_proofs.verify_branch_for_commit`,
`author_proofs.detect_branch_drift`):
- current branch (`git branch --show-current`) equals the intended
feature branch from the issue claim
- current branch is not `master`, `main`, `develop`, `development`, or
`dev`
- branch and `HEAD` have not changed since validation (step 8) — in a
shared checkout another session may switch branches mid-session;
treat that as expected and **stop before committing** when detected
If any check fails, stop and reconcile; do not commit.
10. Commit with an issue-linked message.
11. **Branch proof before push (#177):** prove that the local branch, the
push target branch, and the intended issue branch all match, and that
none of them is a protected branch
(`author_proofs.verify_push_target`). If a commit accidentally landed
on a protected branch, do **not** push: report the accident and the
exact repair steps (`author_proofs.assess_protected_branch_commit`) —
never silently continue after a repair.
12. Push the branch.
13. Open a PR to `master`. The final report must include the branch proofs
from steps 9 and 11 (`author_proofs.build_commit_push_report`).
14. **If you are the author, stop before review/merge.**
15. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
16. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
- why the PR merge path could not be used
- exact commits pushed
- PR metadata state
@@ -181,24 +202,42 @@ Worktree folder = branch with `/` replaced by `-`
that the diff base is the PR base branch. If `HEAD` does not match the
pinned head, **stop before review/merge**
(`review_proofs.verify_pinned_head_checkout`).
6. **Inventory proof (#173):** a blind queue review must prove listing
completeness before claiming "only PRs found": both configured
repositories checked, open-PR filters stated, pagination handled or
explicitly not needed, and the total open PR count per repo reported
(`review_proofs.assess_inventory_completeness`).
7. Inspect the full diff; confirm scope matches the linked issue; flag unrelated files.
8. Run the tests. Validation reporting must include the exact command and
6. **Inventory proof (#173 + repo disambiguation hardening):** a blind queue
review must prove listing completeness before claiming "only PRs found".
Use repo-name disambiguation:
- "Gitea-Tools" / "gitea tool" / "MCP Gitea tool" / "gitea MCP tool" /
"gitea-tools repo" resolve **only** to `Scaled-Tech-Consulting/Gitea-Tools`.
- "mcp-control-plane" resolves only to `Scaled-Tech-Consulting/mcp-control-plane`.
- Ambiguous ("open PRs", no explicit repo, "MCP Gitea tooling") → inventory
**both** configured repos.
Report must state exactly which repo(s) were checked. If only one checked:
"Only <repo> was checked. Other configured repos were not checked. This is
not a complete queue inventory." Never let a single-repo zero hide PRs in
the other.
Both configured repos must be reported with state filter, pagination proof,
and open-PR count (`review_proofs.assess_inventory_completeness` and
`resolve_repos_from_user_reference`).
7. **Role-boundary proof (#175):** a reviewer queue task must not silently
become author implementation. If no eligible PR exists, stop with the
queue report. Do not claim issues, create branches, commit, push, or open
PRs unless the operator explicitly retasks the run as author work. Mixed
reviewer+author namespace use must be reported with a justification, and
scratch-only notes are not durable evidence unless posted or committed
intentionally (`review_proofs.assess_role_boundary`).
8. Inspect the full diff; confirm scope matches the linked issue; flag unrelated files.
9. Run the tests. Validation reporting must include the exact command and
exact results: pass/fail, counts of tests passed/skipped/failed, any
ignored paths and why they are safe to ignore, and whether the command
differs from the repository's canonical validation command. Only claim a
validation result after the command has completed and its output has
been read (`review_proofs.assess_validation_report`).
9. **Do not merge if checks fail. Do not merge if the reviewer is the author.**
10. The final report must distinguish (`review_proofs.build_final_report`):
10. **Do not merge if checks fail. Do not merge if the reviewer is the author.**
11. The final report must distinguish (`review_proofs.build_final_report`):
identity eligible; PR author different from reviewer; session
contamination absent (with evidence); validation performed on the pinned
head; merge performed; issue status verified. If any proof is missing,
stop or downgrade the result instead of merging confidently.
contamination absent (with evidence); role boundary clean; validation
performed on the pinned head; merge performed; issue status verified. If
any proof is missing, stop or downgrade the result instead of merging
confidently.
## G. Merge / cleanup workflow
@@ -5,11 +5,28 @@ Copy, fill the `<...>` fields, and paste as the task prompt.
```text
Task: review PR #<pr> for issue #<n>.
Repo name disambiguation (Gitea-Tools blind review hardening):
- "Gitea-Tools", "gitea tool", "MCP Gitea tool", "gitea MCP tool", "gitea-tools repo"
→ MUST resolve to `Scaled-Tech-Consulting/Gitea-Tools` (never treat as mcp-control-plane).
- "mcp-control-plane", "mcp control plane" → only `Scaled-Tech-Consulting/mcp-control-plane`.
- If user says "open PRs", "the queue", "MCP Gitea tooling" without explicit repo,
or reference is ambiguous: check BOTH configured repos:
`Scaled-Tech-Consulting/Gitea-Tools` and `Scaled-Tech-Consulting/mcp-control-plane`.
- In the final report, always state exactly which repo(s) were checked.
If only one was checked: explicitly say "Only <repo> was checked. Other
configured repos were not checked. This is not a complete queue inventory."
- A single-repo "no open PRs" result MUST NOT be reported as global "no open PRs"
if the other configured repo was not inventoried.
Rules (llm-project-workflow):
- Review in a SEPARATE detached review worktree, never the author's folder.
- You must NOT be the PR author. If the authenticated user == PR author, stop.
A different LLM-Agent-SHA does NOT make you a different actor — only a
different authenticated Gitea user does (docs/llm-agent-sha.md).
- Do not pivot from a reviewer queue task into author implementation unless
the operator explicitly retasks the run. If author namespace was used, the
final report must justify why; author mutations after reviewer queue work
without explicit authorization are a role-boundary violation.
- Do not merge if any check fails.
Steps:
@@ -26,6 +43,10 @@ Steps:
cannot evidence whether this session authored/touched the PR branch,
report contamination as UNKNOWN (not contaminated, not clean) and choose
another PR or stop.
Role-boundary claims must also be evidence-backed (#175): report whether
reviewer namespace, author namespace, author mutations, or review mutations
occurred. Use `review_proofs.assess_role_boundary`; if it is not clean,
downgrade or stop instead of claiming an A-level run.
5. scripts/worktree-review <pr-head-branch> # detached, branches/review-*
cd branches/review-<pr-head-branch-slug>
6. Checkout proof (#173) — prove and state, before any diff review or
@@ -26,8 +26,19 @@ Steps:
cd branches/<type>-issue-<n>-<slug>
6. Implement the narrow scope only; add/update focused tests if behavior changes.
7. Checks: run the test suite, compile/lint changed files, git diff --check,
and scan the diff for secrets.
8. Commit (issue-linked message), push the branch, open a PR to master.
and scan the diff for secrets. Record the branch name and HEAD SHA at
validation time.
8. Branch proof before commit (#177) — prove and state:
- git branch --show-current == the intended issue branch from step 5
- the branch is NOT master/main/develop/development/dev
- branch and HEAD unchanged since step 7 (another session can switch a
shared checkout mid-session; if drift is detected, STOP and reconcile
before committing)
If a commit accidentally lands on a protected branch: do NOT push;
report the accident and the exact repair steps — never silently continue.
9. Commit (issue-linked message). Branch proof before push (#177): local
branch == push target branch == intended issue branch, none protected.
Then push the branch and open a PR to master.
*The PR body MUST use closing keywords like `Closes #N` or `Fixes #N` to close the issue; do NOT use `Implements #N` or `Refs #N` for closing, as Gitea will not auto-close it.*
Include an "LLM Handoff Metadata" block in the PR body (attribution only;
never an eligibility input — docs/llm-agent-sha.md):
@@ -40,7 +51,7 @@ Steps:
- Branch: <branch>
- Worktree: <worktree path>
- Self-review allowed: no
9. Stop before review/merge — you are the author.
10. Stop before review/merge — you are the author.
Handoff: end with a section titled exactly `Controller Handoff` per SKILL.md
§K (compact; long form only on the high-risk triggers), including the author
+234
View File
@@ -0,0 +1,234 @@
"""Tests for author-side branch-identity proofs (Issue #177).
Issue #177 (author-side counterpart of the #173 reviewer proofs) requires
author workflows to *prove* local git state before staging, committing, or
pushing, instead of discovering drift after the fact:
1. The current branch equals the intended feature branch and is never a
protected branch (master/main/develop/development/dev).
2. Branch or HEAD drift between validation and commit — including external
branch switches in a shared worktree — stops the workflow.
3. A push requires local branch, remote target branch, and intended issue
branch to all match.
4. An accidental commit on a protected branch must not be pushed and its
repair must be reported, never silently continued.
These are the harness assertions from the issue's Required behavior 5.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from author_proofs import ( # noqa: E402
PROTECTED_BRANCHES,
assess_protected_branch_commit,
build_commit_push_report,
detect_branch_drift,
verify_branch_for_commit,
verify_push_target,
)
FEATURE = "feat/issue-177-branch-drift-proofs"
HEAD_1 = "64dc334a92685b7b6a1fdb7ffe363f02a69f5dbd"
HEAD_2 = "ccc5ef79dfe629853e144763238593bd808d57e0"
class TestProtectedBranches(unittest.TestCase):
def test_known_protected_names(self):
for name in ("master", "main", "develop", "development", "dev"):
self.assertIn(name, PROTECTED_BRANCHES)
class TestVerifyBranchForCommit(unittest.TestCase):
"""Required behavior 1: prove the branch before staging/committing."""
def test_on_intended_feature_branch_is_proven(self):
proof = verify_branch_for_commit(FEATURE, FEATURE)
self.assertTrue(proof["proven"])
self.assertFalse(proof["block"])
def test_commit_attempted_while_on_master_is_blocked(self):
# Harness assertion (behavior 5, bullet 1).
proof = verify_branch_for_commit("master", FEATURE)
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
self.assertTrue(any("master" in r for r in proof["reasons"]))
def test_every_protected_branch_is_blocked_as_current(self):
for name in PROTECTED_BRANCHES:
proof = verify_branch_for_commit(name, FEATURE)
self.assertTrue(proof["block"], name)
def test_intended_branch_may_not_be_protected(self):
proof = verify_branch_for_commit("master", "master")
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
def test_wrong_feature_branch_is_blocked(self):
proof = verify_branch_for_commit("feat/issue-178-other-work", FEATURE)
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
def test_missing_current_branch_fails_closed(self):
proof = verify_branch_for_commit("", FEATURE)
self.assertTrue(proof["block"])
def test_missing_intended_branch_fails_closed(self):
proof = verify_branch_for_commit(FEATURE, None)
self.assertTrue(proof["block"])
class TestBranchDrift(unittest.TestCase):
"""Required behaviors 2 + 3: drift between validation and commit stops
the workflow."""
def test_no_drift_when_branch_and_head_unchanged(self):
drift = detect_branch_drift(FEATURE, HEAD_1, FEATURE, HEAD_1)
self.assertFalse(drift["drifted"])
self.assertFalse(drift["block"])
def test_branch_drift_between_validation_and_commit_is_blocked(self):
# Harness assertion (behavior 5, bullet 2).
drift = detect_branch_drift(FEATURE, HEAD_1, "feat/other", HEAD_1)
self.assertTrue(drift["drifted"])
self.assertTrue(drift["block"])
def test_shared_worktree_branch_switch_is_detected(self):
# Harness assertion (behavior 5, bullet 4): an external session
# switching the shared checkout to another branch (e.g. master)
# must be detected as drift, not treated as exceptional noise.
drift = detect_branch_drift(FEATURE, HEAD_1, "master", HEAD_1)
self.assertTrue(drift["drifted"])
self.assertTrue(drift["block"])
self.assertTrue(any("switch" in r.lower() for r in drift["reasons"]))
def test_head_moved_since_validation_is_blocked(self):
drift = detect_branch_drift(FEATURE, HEAD_1, FEATURE, HEAD_2)
self.assertTrue(drift["drifted"])
self.assertTrue(drift["block"])
self.assertTrue(any("HEAD" in r for r in drift["reasons"]))
def test_missing_state_fails_closed(self):
drift = detect_branch_drift(FEATURE, HEAD_1, FEATURE, None)
self.assertTrue(drift["drifted"])
self.assertTrue(drift["block"])
class TestVerifyPushTarget(unittest.TestCase):
"""Required behavior 1 (push leg) + acceptance: push needs proof that
local, remote, and intended branches all match."""
def test_matching_local_remote_and_intended_is_proven(self):
proof = verify_push_target(FEATURE, FEATURE, FEATURE)
self.assertTrue(proof["proven"])
self.assertFalse(proof["block"])
def test_push_target_mismatch_is_blocked(self):
# Harness assertion (behavior 5, bullet 3).
proof = verify_push_target(FEATURE, "feat/issue-178-other-work", FEATURE)
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
def test_local_branch_differs_from_intended_is_blocked(self):
proof = verify_push_target("feat/other", FEATURE, FEATURE)
self.assertTrue(proof["block"])
def test_pushing_a_protected_branch_is_blocked(self):
proof = verify_push_target("master", "master", "master")
self.assertFalse(proof["proven"])
self.assertTrue(proof["block"])
def test_missing_remote_target_fails_closed(self):
proof = verify_push_target(FEATURE, "", FEATURE)
self.assertTrue(proof["block"])
class TestProtectedBranchAccident(unittest.TestCase):
"""Required behavior 4: accidental protected-branch commits must not be
pushed and their repair must be reported."""
def test_feature_branch_commit_is_not_an_accident(self):
result = assess_protected_branch_commit(FEATURE)
self.assertFalse(result["accident"])
self.assertEqual(result["violations"], [])
def test_commit_on_master_is_an_accident_and_must_not_push(self):
result = assess_protected_branch_commit(
"master", pushed=False, repair_reported=True
)
self.assertTrue(result["accident"])
self.assertTrue(result["must_not_push"])
self.assertEqual(result["violations"], [])
self.assertTrue(result["repair_required"])
def test_pushing_the_accident_is_a_violation(self):
result = assess_protected_branch_commit(
"master", pushed=True, repair_reported=True
)
self.assertTrue(any("push" in v.lower() for v in result["violations"]))
def test_silent_repair_is_a_violation(self):
# Harness assertion (behavior 5, bullet 5): the repair path must not
# silently continue without reporting.
result = assess_protected_branch_commit(
"master", pushed=False, repair_reported=False
)
self.assertTrue(any("report" in v.lower() for v in result["violations"]))
class TestCommitPushReport(unittest.TestCase):
"""Acceptance criteria: the final report includes branch proof before
commit and before push, and blocks instead of continuing."""
def _report(self, **overrides):
kwargs = {
"commit_proof": verify_branch_for_commit(FEATURE, FEATURE),
"drift": detect_branch_drift(FEATURE, HEAD_1, FEATURE, HEAD_1),
"push_proof": verify_push_target(FEATURE, FEATURE, FEATURE),
"accident": assess_protected_branch_commit(FEATURE),
}
kwargs.update(overrides)
return build_commit_push_report(**kwargs)
def test_fully_proven_report_is_ok(self):
report = self._report()
self.assertEqual(report["status"], "ok")
self.assertTrue(report["branch_proof_before_commit"])
self.assertTrue(report["branch_proof_before_push"])
self.assertFalse(report["drift_detected"])
self.assertEqual(report["violations"], [])
def test_commit_proof_failure_blocks(self):
report = self._report(
commit_proof=verify_branch_for_commit("master", FEATURE)
)
self.assertEqual(report["status"], "blocked")
self.assertFalse(report["branch_proof_before_commit"])
def test_drift_blocks(self):
report = self._report(
drift=detect_branch_drift(FEATURE, HEAD_1, "master", HEAD_1)
)
self.assertEqual(report["status"], "blocked")
self.assertTrue(report["drift_detected"])
def test_push_proof_failure_blocks(self):
report = self._report(
push_proof=verify_push_target(FEATURE, "feat/other", FEATURE)
)
self.assertEqual(report["status"], "blocked")
self.assertFalse(report["branch_proof_before_push"])
def test_accident_violations_block(self):
report = self._report(
accident=assess_protected_branch_commit(
"master", pushed=False, repair_reported=False
)
)
self.assertEqual(report["status"], "blocked")
self.assertTrue(report["violations"])
if __name__ == "__main__":
unittest.main()
+9 -4
View File
@@ -11,6 +11,7 @@ import json
import sys
import tempfile
import unittest
import contextlib
from unittest.mock import MagicMock, patch
# The module under test lives in the repo root, not a package.
@@ -38,7 +39,8 @@ class TestArgParsing(unittest.TestCase):
@patch("create_issue.api_request", return_value={"number": 1, "html_url": "http://x/1"})
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_minimal_args(self, _cred, _api):
rc = create_issue.main(["--title", "Hello"])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_issue.main(["--title", "Hello"])
self.assertEqual(rc, 0)
def test_missing_title_exits(self):
@@ -50,7 +52,8 @@ class TestArgParsing(unittest.TestCase):
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_remote_choices(self, _cred, _api):
for remote in ("dadeschools", "prgs"):
rc = create_issue.main(["--remote", remote, "--title", "X"])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_issue.main(["--remote", remote, "--title", "X"])
self.assertEqual(rc, 0, f"--remote {remote} should be accepted")
def test_invalid_remote_exits(self):
@@ -138,7 +141,8 @@ class TestAuthFailure(unittest.TestCase):
@patch("create_issue.get_credentials", return_value=("", ""))
def test_no_credentials_returns_1(self, _cred):
rc = create_issue.main(["--title", "T"])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_issue.main(["--title", "T"])
self.assertEqual(rc, 1)
@@ -152,7 +156,8 @@ class TestAPIError(unittest.TestCase):
def test_api_error_returns_1(self, _cred):
with patch("create_issue.api_request",
side_effect=RuntimeError("HTTP 422: duplicate")):
rc = create_issue.main(["--title", "Dup"])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_issue.main(["--title", "Dup"])
self.assertEqual(rc, 1)
+3 -1
View File
@@ -7,6 +7,7 @@ import io
import json
import sys
import unittest
import contextlib
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -34,7 +35,8 @@ class TestArgParsing(unittest.TestCase):
@patch("create_pr.urllib.request.urlopen", return_value=_mock_urlopen())
@patch("create_pr.get_credentials", return_value=FAKE_CREDS)
def test_minimal_required_args(self, _cred, _url):
rc = create_pr.main(["--title", "PR Title", "--head", "feat/branch"])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = create_pr.main(["--title", "PR Title", "--head", "feat/branch"])
self.assertEqual(rc, 0)
def test_missing_title_exits(self):
+10 -4
View File
@@ -2,9 +2,11 @@
All API calls are mocked — no real network or keychain access.
"""
import io
import json
import sys
import unittest
import contextlib
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -33,7 +35,8 @@ class TestLabelCreation(unittest.TestCase):
# Patch sys.argv to avoid --dry
with patch.object(sys, "argv", ["manage_labels.py"]):
manage_labels.main()
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main()
# The GET call happens, but no POST calls for label creation
get_calls = [c for c in mock_api.call_args_list if c[0][0] == "GET"]
@@ -59,7 +62,8 @@ class TestLabelCreation(unittest.TestCase):
mock_api.side_effect = side_effect
with patch.object(sys, "argv", ["manage_labels.py"]):
manage_labels.main()
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main()
post_calls = [
c for c in mock_api.call_args_list
@@ -79,7 +83,8 @@ class TestDryRun(unittest.TestCase):
mock_api.return_value = [] # no existing labels
with patch.object(sys, "argv", ["manage_labels.py", "--dry"]):
manage_labels.main()
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main()
# Only the GET call should be made, no POST or PUT
for c in mock_api.call_args_list:
@@ -107,7 +112,8 @@ class TestLabelMapping(unittest.TestCase):
mock_api.side_effect = side_effect
with patch.object(sys, "argv", ["manage_labels.py"]):
manage_labels.main()
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
manage_labels.main()
put_calls = [c for c in mock_api.call_args_list if c[0][0] == "PUT"]
self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
+269 -4
View File
@@ -5,6 +5,7 @@ the MCP protocol) with mocked API responses.
"""
import json
import os
os.environ["GITEA_TEST_ENVIRONMENT"] = "1"
import sys
import unittest
from unittest.mock import patch, MagicMock
@@ -33,10 +34,16 @@ from mcp_server import ( # noqa: E402
gitea_submit_pr_review,
gitea_list_issue_comments,
gitea_create_issue_comment,
gitea_lock_issue,
)
from gitea_auth import get_profile # noqa: E402
import gitea_config # noqa: E402
import mcp_server
# Globally disable verification check for existing isolated unit tests
_real_verify = mcp_server.verify_mutation_authority
mcp_server.verify_mutation_authority = lambda *args, **kwargs: None
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
@@ -85,10 +92,13 @@ class TestCreatePR(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_pr(self, _auth, mock_api):
@patch("os.path.exists", return_value=True)
@patch("builtins.open")
def test_creates_pr(self, mock_open, mock_exists, _auth, mock_api):
mock_open.return_value.__enter__.return_value.read.return_value = '{"issue_number": 123, "branch_name": "feat/x"}'
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
with patch.dict(os.environ, {}, clear=True):
result = gitea_create_pr(title="feat: X", head="feat/x", base="main")
result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
self.assertEqual(result["number"], 3)
self.assertNotIn("url", result)
payload = mock_api.call_args[0][3]
@@ -97,10 +107,13 @@ class TestCreatePR(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api):
@patch("os.path.exists", return_value=True)
@patch("builtins.open")
def test_create_pr_reveal_opt_in_includes_url(self, mock_open, mock_exists, _auth, mock_api):
mock_open.return_value.__enter__.return_value.read.return_value = '{"issue_number": 123, "branch_name": "feat/x"}'
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
with patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True):
result = gitea_create_pr(title="feat: X", head="feat/x", base="main")
result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main")
self.assertIn("pulls/3", result["url"])
@@ -2296,3 +2309,255 @@ class TestIssueCommentPermissionSeparation(unittest.TestCase):
"gitea.issue.comment", reviewer["allowed_operations"],
reviewer.get("forbidden_operations", []))
self.assertTrue(ok)
class TestVerifyMutationAuthority(unittest.TestCase):
"""Test verification lock logic under various configurations."""
def setUp(self):
self.patch_profile = patch("mcp_server.get_profile")
self.mock_profile = self.patch_profile.start()
self.patch_username = patch("mcp_server._authenticated_username")
self.mock_username = self.patch_username.start()
# Restore real function for these tests
self._old_verify = mcp_server.verify_mutation_authority
mcp_server.verify_mutation_authority = _real_verify
def tearDown(self):
self.patch_profile.stop()
self.patch_username.stop()
mcp_server.verify_mutation_authority = self._old_verify
# Clean up lock file
if os.path.exists("/tmp/gitea_mutation_authority.lock"):
os.remove("/tmp/gitea_mutation_authority.lock")
def test_missing_lock_fails_closed(self):
if os.path.exists("/tmp/gitea_mutation_authority.lock"):
os.remove("/tmp/gitea_mutation_authority.lock")
with self.assertRaises(RuntimeError) as ctx:
_real_verify("prgs")
self.assertIn("lock is missing", str(ctx.exception))
def test_mismatched_remote_fails(self):
with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
json.dump({
"remote": "dadeschools",
"current_profile": "prgs-reviewer",
"current_identity": "sysadmin"
}, f)
with self.assertRaises(RuntimeError) as ctx:
_real_verify("prgs")
self.assertIn("does not match locked remote", str(ctx.exception))
def test_mismatched_profile_fails(self):
with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
json.dump({
"remote": "prgs",
"current_profile": "prgs-author",
"current_identity": "jcwalker3"
}, f)
self.mock_profile.return_value = {"profile_name": "prgs-reviewer"}
self.mock_username.return_value = "sysadmin"
with self.assertRaises(RuntimeError) as ctx:
_real_verify("prgs")
self.assertIn("does not match locked authority", str(ctx.exception))
def test_author_to_reviewer_pivot_blocked_without_authorization(self):
with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
json.dump({
"remote": "prgs",
"initial_profile": "prgs-author",
"initial_identity": "jcwalker3",
"current_profile": "prgs-reviewer",
"current_identity": "sysadmin",
"role_pivot_authorized": False
}, f)
self.mock_profile.return_value = {"profile_name": "prgs-reviewer"}
self.mock_username.return_value = "sysadmin"
with self.assertRaises(RuntimeError) as ctx:
_real_verify("prgs", required_role="reviewer")
self.assertIn("without authorized role pivot", str(ctx.exception))
def test_allowed_when_match(self):
with open("/tmp/gitea_mutation_authority.lock", "w", encoding="utf-8") as f:
json.dump({
"remote": "prgs",
"initial_profile": "prgs-reviewer",
"initial_identity": "sysadmin",
"current_profile": "prgs-reviewer",
"current_identity": "sysadmin",
"role_pivot_authorized": False
}, f)
self.mock_profile.return_value = {"profile_name": "prgs-reviewer"}
self.mock_username.return_value = "sysadmin"
# Should pass without exception
_real_verify("prgs")
class TestIssueLocking(unittest.TestCase):
"""Test issue locking and PR gating constraints."""
def tearDown(self):
if os.path.exists("/tmp/gitea_issue_lock.json"):
os.remove("/tmp/gitea_issue_lock.json")
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_success(self, _auth, mock_api):
mock_api.return_value = [] # no open PRs
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertTrue(res["success"])
self.assertTrue(os.path.exists("/tmp/gitea_issue_lock.json"))
def test_lock_issue_mismatch_branch_fails(self):
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-195-mutations", remote="prgs")
self.assertIn("must contain locked issue pattern", str(ctx.exception))
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_reused_by_open_pr_branch(self, _auth, mock_api):
mock_api.return_value = [{
"number": 200,
"head": {"ref": "feat/issue-196-boundary"},
"title": "Some PR",
"body": "No closes ref"
}]
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already tied to an open PR", str(ctx.exception))
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, mock_api):
mock_api.return_value = [{
"number": 200,
"head": {"ref": "feat/other-branch"},
"title": "Some PR",
"body": "fixes #196"
}]
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already tied to an open PR", str(ctx.exception))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_lock_fails(self, _auth):
if os.path.exists("/tmp/gitea_issue_lock.json"):
os.remove("/tmp/gitea_issue_lock.json")
with self.assertRaises(RuntimeError) as ctx:
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("Issue lock is missing", str(ctx.exception))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_branch_mismatch_fails(self, _auth):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-different", remote="prgs")
self.assertIn("does not match locked branch", str(ctx.exception))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_forbidden_terms_fails(self, _auth):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
for term in ("equivalent to #196", "related to #196", "same as #196"):
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title=f"feat: X {term}", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("contains forbidden term", str(ctx.exception))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_missing_closes_ref_fails(self, _auth):
with open("/tmp/gitea_issue_lock.json", "w", encoding="utf-8") as f:
json.dump({"issue_number": 196, "branch_name": "feat/issue-196-mutations"}, f)
with self.assertRaises(ValueError) as ctx:
gitea_create_pr(title="feat: X refs #196", head="feat/issue-196-mutations", remote="prgs")
self.assertIn("must contain 'Closes #196' or 'Fixes #196' exactly", str(ctx.exception))
class TestPreflightVerification(unittest.TestCase):
"""Test workspace edits and pre-flight ordering verification."""
def setUp(self):
self.preflight_path = "/tmp/gitea_preflight_check.json"
if os.path.exists(self.preflight_path):
os.remove(self.preflight_path)
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
def tearDown(self):
if os.path.exists(self.preflight_path):
os.remove(self.preflight_path)
os.environ.pop("GITEA_TEST_FORCE_DIRTY", None)
@patch("subprocess.run")
def test_record_preflight_detects_violation_whoami(self, mock_run):
mock_run.return_value = MagicMock(stdout="M mcp_server.py\n")
from mcp_server import record_preflight_check, verify_preflight_purity
record_preflight_check("whoami")
with open(self.preflight_path, "r") as f:
data = json.load(f)
self.assertTrue(data.get("whoami_preflight_violation"))
self.assertTrue(data.get("whoami_called"))
data["capability_called"] = True
with open(self.preflight_path, "w") as f:
json.dump(data, f)
with self.assertRaises(RuntimeError) as ctx:
verify_preflight_purity("prgs")
self.assertIn("Workspace file edits occurred before gitea_whoami", str(ctx.exception))
@patch("subprocess.run")
def test_record_preflight_detects_violation_capability(self, mock_run):
mock_run.return_value = MagicMock(stdout="M mcp_server.py\n")
from mcp_server import record_preflight_check, verify_preflight_purity
record_preflight_check("capability", resolved_role="author")
with open(self.preflight_path, "r") as f:
data = json.load(f)
self.assertTrue(data.get("capability_preflight_violation"))
self.assertTrue(data.get("capability_called"))
self.assertEqual(data.get("role"), "author")
data["whoami_called"] = True
with open(self.preflight_path, "w") as f:
json.dump(data, f)
with self.assertRaises(RuntimeError) as ctx:
verify_preflight_purity("prgs")
self.assertIn("Workspace file edits occurred before gitea_resolve_task_capability", str(ctx.exception))
@patch("subprocess.run")
def test_verify_preflight_reviewer_edits_blocked(self, mock_run):
with open(self.preflight_path, "w") as f:
json.dump({
"whoami_called": True,
"capability_called": True,
"role": "reviewer"
}, f)
mock_run.return_value = MagicMock(stdout="M review_proofs.py\n")
from mcp_server import verify_preflight_purity
with self.assertRaises(RuntimeError) as ctx:
verify_preflight_purity("prgs")
self.assertIn("Reviewer profile is forbidden from modifying tracked workspace files", str(ctx.exception))
@patch("subprocess.run")
def test_verify_preflight_clean_reviewer_allowed(self, mock_run):
with open(self.preflight_path, "w") as f:
json.dump({
"whoami_called": True,
"capability_called": True,
"role": "reviewer"
}, f)
mock_run.return_value = MagicMock(stdout="")
from mcp_server import verify_preflight_purity
verify_preflight_purity("prgs")
def test_verify_preflight_skipped_fails(self):
from mcp_server import verify_preflight_purity
with self.assertRaises(RuntimeError) as ctx:
verify_preflight_purity("prgs")
self.assertIn("verification were skipped", str(ctx.exception))
+6 -2
View File
@@ -45,13 +45,17 @@ class TestMergeDisabled(unittest.TestCase):
mock_api.assert_not_called()
def test_message_points_to_gated_workflow(self):
from _pytest.monkeypatch import MonkeyPatch
import io
import contextlib
with patch("merge_pr.get_auth_header", return_value=FAKE_CREDS), \
patch("merge_pr.api_request") as mock_api:
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
monkeypatch = MonkeyPatch()
monkeypatch.setattr(sys, "stderr", buf)
try:
rc = merge_pr.main(["--pr-number", "81"])
finally:
monkeypatch.undo()
self.assertEqual(rc, 2)
mock_api.assert_not_called()
msg = buf.getvalue().lower()
+29 -17
View File
@@ -9,6 +9,8 @@ import shutil
from unittest.mock import patch
from io import StringIO
from _pytest.monkeypatch import MonkeyPatch
# Add project root to sys.path
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
@@ -127,24 +129,29 @@ class TestMigrateProfiles(unittest.TestCase):
v2_data = migrate_profiles.migrate_v1_to_v2(self.v1_content)
self.assertTrue(migrate_profiles.validate_v2_data(v2_data))
@patch("sys.stdout", new_callable=StringIO)
def test_dry_run_default(self, mock_stdout):
def test_dry_run_default(self):
"""Verify that running without -w prints generated config without modifying files."""
output_file = os.path.join(self.temp_dir, "migrated_dry.json")
test_args = [
"migrate_profiles.py",
"-i", self.input_file,
"-o", output_file
]
with patch.object(sys, "argv", test_args):
with self.assertRaises(SystemExit) as cm:
migrate_profiles.main()
self.assertEqual(cm.exception.code, 0)
monkeypatch = MonkeyPatch()
mock_stdout = StringIO()
monkeypatch.setattr(sys, "stdout", mock_stdout)
try:
output_file = os.path.join(self.temp_dir, "migrated_dry.json")
test_args = [
"migrate_profiles.py",
"-i", self.input_file,
"-o", output_file
]
with patch.object(sys, "argv", test_args):
with self.assertRaises(SystemExit) as cm:
migrate_profiles.main()
self.assertEqual(cm.exception.code, 0)
self.assertFalse(os.path.exists(output_file))
self.assertFalse(os.path.exists(f"{self.input_file}.bak"))
self.assertFalse(os.path.exists(output_file))
self.assertFalse(os.path.exists(f"{self.input_file}.bak"))
stdout_output = mock_stdout.getvalue()
stdout_output = mock_stdout.getvalue()
finally:
monkeypatch.undo()
self.assertIn("DRY-RUN MODE", stdout_output)
self.assertIn("version", stdout_output)
self.assertIn("identities", stdout_output)
@@ -165,13 +172,18 @@ class TestMigrateProfiles(unittest.TestCase):
json.dump(sensitive, f)
test_args = ["migrate_profiles.py", "-i", self.input_file]
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
monkeypatch = MonkeyPatch()
mock_stdout = StringIO()
monkeypatch.setattr(sys, "stdout", mock_stdout)
try:
with patch.object(sys, "argv", test_args):
with self.assertRaises(SystemExit) as cm:
migrate_profiles.main()
self.assertEqual(cm.exception.code, 0)
stdout_output = mock_stdout.getvalue()
stdout_output = mock_stdout.getvalue()
finally:
monkeypatch.undo()
self.assertNotIn("super-secret-token-value", stdout_output)
self.assertNotIn("token", stdout_output.lower())
+15 -4
View File
@@ -46,8 +46,8 @@ EXPECTED_SKILLS = [
"gitea-resolve-task-capability",
"profile-switching",
"redaction-security-review",
"jenkins-readonly",
"glitchtip-readonly",
"jenkins-mcp",
"glitchtip-mcp",
"release-operator",
]
@@ -234,8 +234,8 @@ class TestProjectSkills(GuideTestBase):
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
r = mcp_list_project_skills()
by_name = {s["name"]: s for s in r["skills"]}
self.assertNotEqual(by_name["jenkins-readonly"]["status"], "available")
self.assertNotEqual(by_name["glitchtip-readonly"]["status"], "available")
self.assertNotEqual(by_name["jenkins-mcp"]["status"], "available")
self.assertNotEqual(by_name["glitchtip-mcp"]["status"], "available")
def test_no_urls_in_registry(self):
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
@@ -245,6 +245,17 @@ class TestProjectSkills(GuideTestBase):
self.assertNotIn("http://", blob)
self.assertNotIn("keychain:", blob)
def test_enabled_but_no_usable_tools_negative_assertion(self):
"""Negative assertion for 'enabled but no usable tools' (per issue #146)."""
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
r = mcp_list_project_skills()
by_name = {s["name"]: s for s in r["skills"]}
# jenkins-mcp is designed-not-implemented; even if "enabled" in config,
# it should not be usable/available to current profile without tools.
self.assertIn("jenkins-mcp", by_name)
self.assertEqual(by_name["jenkins-mcp"]["status"], "designed-not-implemented")
self.assertFalse(by_name["jenkins-mcp"].get("available_to_current_profile", False))
# ---------------------------------------------------------------------------
# mcp_get_skill_guide
+6 -2
View File
@@ -4,6 +4,8 @@ Mocks api_request and credentials.
"""
import sys
import unittest
import io
import contextlib
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -24,14 +26,16 @@ class TestListPRs(unittest.TestCase):
mock_api.return_value = [
{"number": 1, "title": "PR 1", "head": {"ref": "branch1"}, "base": {"ref": "main"}, "html_url": "http://url1", "mergeable": True}
]
rc = list_prs.main([])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = list_prs.main([])
self.assertEqual(rc, 0)
mock_api.assert_called_once()
@patch("list_prs.api_request", return_value=[])
@patch("list_prs.get_auth_header", return_value=FAKE_CREDS)
def test_list_prs_empty(self, _auth, mock_api):
rc = list_prs.main(["--state", "closed"])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = list_prs.main(["--state", "closed"])
self.assertEqual(rc, 0)
mock_api.assert_called_once()
+6 -2
View File
@@ -4,6 +4,8 @@ All tests mock credentials and API requests so no real network calls are made.
"""
import sys
import unittest
import io
import contextlib
from unittest.mock import patch, MagicMock
# The modules under test live in the repo root
@@ -31,7 +33,8 @@ class TestCloseIssueCLI(unittest.TestCase):
@patch("close_issue.api_request")
@patch("close_issue.get_auth_header", return_value=FAKE_AUTH)
def test_successful_close(self, _auth, mock_api):
rc = close_issue.main(["42"])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = close_issue.main(["42"])
self.assertEqual(rc, 0)
mock_api.assert_called_once()
url = mock_api.call_args[0][1]
@@ -69,7 +72,8 @@ class TestMarkIssueCLI(unittest.TestCase):
[{"id": 101, "name": "status:in-progress"}],
[{"name": "status:in-progress"}],
]
rc = mark_issue.main(["15", "start"])
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
rc = mark_issue.main(["15", "start"])
self.assertEqual(rc, 0)
self.assertEqual(mock_api.call_count, 2)
+83 -2
View File
@@ -2,6 +2,8 @@
Mocks api_request and credentials.
"""
import io
import os
import sys
import unittest
from unittest.mock import patch
@@ -27,6 +29,11 @@ FAKE_PR_DATA = {
class TestArgParsing(unittest.TestCase):
def setUp(self):
self.exists_patcher = patch("os.path.exists", return_value=False)
self.exists_patcher.start()
self.addCleanup(self.exists_patcher.stop)
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_missing_pr_number_exits(self, _auth):
with self.assertRaises(SystemExit):
@@ -35,6 +42,11 @@ class TestArgParsing(unittest.TestCase):
class TestAPIPayload(unittest.TestCase):
def setUp(self):
self.exists_patcher = patch("os.path.exists", return_value=False)
self.exists_patcher.start()
self.addCleanup(self.exists_patcher.stop)
@patch("review_pr.api_request")
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_payload_fields_and_workflow(self, _auth, mock_api):
@@ -79,15 +91,19 @@ class TestAPIPayload(unittest.TestCase):
self.assertEqual(mock_api.call_count, 0)
def test_merge_flag_message_points_to_gated_workflow(self):
from _pytest.monkeypatch import MonkeyPatch
import io
import contextlib
with patch("review_pr.get_auth_header", return_value=FAKE_CREDS), \
patch("review_pr.api_request") as mock_api:
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
monkeypatch = MonkeyPatch()
monkeypatch.setattr(sys, "stderr", buf)
try:
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE", "--merge",
])
finally:
monkeypatch.undo()
self.assertEqual(rc, 2)
self.assertEqual(mock_api.call_count, 0)
msg = buf.getvalue().lower()
@@ -95,5 +111,70 @@ class TestAPIPayload(unittest.TestCase):
self.assertIn("gitea_merge_pr", msg)
class TestMutationAuthorityLock(unittest.TestCase):
"""Issue #194: verify that the CLI tool rejects profile overrides when mismatched with lock."""
@patch("review_pr.get_profile")
def test_cli_blocked_on_profile_mismatch(self, mock_get_profile):
mock_get_profile.return_value = {"profile_name": "prgs-reviewer"}
original_exists = os.path.exists
def conditional_exists(path):
if "gitea_mutation_authority.lock" in str(path):
return True
return original_exists(path)
original_open = open
def conditional_open(file, *args, **kwargs):
if "gitea_mutation_authority.lock" in str(file):
return io.StringIO('{"current_profile": "prgs-author"}')
return original_open(file, *args, **kwargs)
from _pytest.monkeypatch import MonkeyPatch
import io
buf = io.StringIO()
monkeypatch = MonkeyPatch()
monkeypatch.setattr(sys, "stderr", buf)
with patch("os.path.exists", side_effect=conditional_exists), \
patch("builtins.open", side_effect=conditional_open):
try:
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
finally:
monkeypatch.undo()
self.assertEqual(rc, 3)
msg = buf.getvalue().lower()
self.assertIn("cli override rejected", msg)
@patch("review_pr.get_profile")
@patch("review_pr.api_request")
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
def test_cli_allowed_on_profile_match(self, _auth, mock_api, mock_get_profile):
mock_get_profile.return_value = {"profile_name": "prgs-reviewer"}
mock_api.side_effect = [FAKE_PR_DATA, {}]
original_exists = os.path.exists
def conditional_exists(path):
if "gitea_mutation_authority.lock" in str(path):
return True
return original_exists(path)
original_open = open
def conditional_open(file, *args, **kwargs):
if "gitea_mutation_authority.lock" in str(file):
return io.StringIO('{"current_profile": "prgs-reviewer"}')
return original_open(file, *args, **kwargs)
with patch("os.path.exists", side_effect=conditional_exists), \
patch("builtins.open", side_effect=conditional_open):
rc = review_pr.main([
"--pr-number", "81", "--event", "APPROVE",
])
self.assertEqual(rc, 0)
if __name__ == "__main__":
unittest.main()
+370
View File
@@ -14,6 +14,7 @@ each precondition of a blind queue review:
These are the harness assertions from the issue's Required behavior 7.
"""
import io
import sys
import unittest
@@ -22,9 +23,12 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par
from review_proofs import ( # noqa: E402
assess_controller_handoff,
assess_inventory_completeness,
assess_role_boundary,
assess_self_review_contamination,
assess_validation_report,
build_final_report,
pr_inventory_trust_gate,
resolve_repos_from_user_reference,
verify_pinned_head_checkout,
)
@@ -97,6 +101,21 @@ def _good_contamination():
)
def _good_role_boundary():
return assess_role_boundary(
{
"task_role": "reviewer",
"task_kind": "blind_pr_queue_review",
"reviewer_namespace_used": True,
"author_namespace_used": False,
"author_mutations": [],
"review_mutations": [],
"operator_authorized_author_work": False,
"scratch_evidence_claimed": False,
}
)
class TestCheckoutProof(unittest.TestCase):
"""Required behavior 1 + 2: prove HEAD == pinned PR head or stop."""
@@ -366,6 +385,74 @@ class TestSelfReviewContamination(unittest.TestCase):
self.assertEqual(result["status"], "unknown")
class TestRoleBoundary(unittest.TestCase):
"""Issue #175: reviewer queue tasks must not pivot into author work."""
def test_reviewer_queue_without_author_mutations_is_clean(self):
result = _good_role_boundary()
self.assertEqual(result["status"], "clean")
self.assertEqual(result["violations"], [])
def test_reviewer_queue_author_mutation_without_authorization_violates(self):
result = assess_role_boundary(
{
"task_role": "reviewer",
"task_kind": "blind_pr_queue_review",
"reviewer_namespace_used": True,
"author_namespace_used": True,
"author_mutations": ["claim issue #171", "push branch"],
"operator_authorized_author_work": False,
"mixed_namespace_justification": (
"author namespace was used for implementation"
),
}
)
self.assertEqual(result["status"], "violation")
self.assertTrue(any("pivot" in r for r in result["violations"]))
def test_mixed_namespace_use_without_justification_is_warning(self):
result = assess_role_boundary(
{
"task_role": "reviewer",
"task_kind": "blind_pr_queue_review",
"reviewer_namespace_used": True,
"author_namespace_used": True,
"author_mutations": [],
}
)
self.assertEqual(result["status"], "warning")
self.assertTrue(
any("mixed" in r.lower() for r in result["reasons"])
)
def test_author_task_cannot_perform_review_mutations(self):
result = assess_role_boundary(
{
"task_role": "author",
"reviewer_namespace_used": False,
"author_namespace_used": True,
"review_mutations": ["approve PR"],
}
)
self.assertEqual(result["status"], "violation")
self.assertTrue(
any("reviewer-only" in r for r in result["violations"])
)
def test_scratch_only_notes_are_not_durable_evidence(self):
result = assess_role_boundary(
{
"task_role": "reviewer",
"task_kind": "blind_pr_queue_review",
"reviewer_namespace_used": True,
"scratch_evidence_claimed": True,
"scratch_evidence_durable": False,
}
)
self.assertEqual(result["status"], "warning")
self.assertTrue(any("scratch-only" in r for r in result["reasons"]))
class TestFinalReport(unittest.TestCase):
"""Required behavior 6 + acceptance criteria: the report must
distinguish each proof, and only a fully proven run earns an "A"."""
@@ -379,6 +466,7 @@ class TestFinalReport(unittest.TestCase):
"identity_eligible": True,
"merge_performed": False,
"issue_status_verified": True,
"role_boundary": _good_role_boundary(),
}
kwargs.update(overrides)
return build_final_report(**kwargs)
@@ -391,6 +479,7 @@ class TestFinalReport(unittest.TestCase):
self.assertTrue(report["identity_eligible"])
self.assertTrue(report["pr_author_distinct_from_reviewer"])
self.assertEqual(report["session_contamination"], "clean")
self.assertEqual(report["role_boundary"], "clean")
self.assertTrue(report["validated_on_pinned_head"])
self.assertFalse(report["merge_performed"])
self.assertTrue(report["issue_status_verified"])
@@ -463,6 +552,148 @@ class TestFinalReport(unittest.TestCase):
self.assertNotEqual(report["grade"], "A")
self.assertFalse(report["merge_allowed"])
def test_missing_role_boundary_downgrades_and_blocks_merge(self):
kwargs = {
"checkout_proof": _good_checkout(),
"inventory": _good_inventory(),
"validation": _good_validation(),
"contamination": _good_contamination(),
"identity_eligible": True,
"merge_performed": False,
"issue_status_verified": True,
}
report = build_final_report(**kwargs)
self.assertNotEqual(report["grade"], "A")
self.assertFalse(report["merge_allowed"])
self.assertEqual(report["role_boundary"], "warning")
def test_role_boundary_violation_blocks_report(self):
boundary = assess_role_boundary(
{
"task_role": "reviewer",
"task_kind": "blind_pr_queue_review",
"reviewer_namespace_used": True,
"author_namespace_used": True,
"author_mutations": ["create PR"],
"operator_authorized_author_work": False,
"mixed_namespace_justification": "implementation pivot",
}
)
report = self._report(role_boundary=boundary)
self.assertEqual(report["grade"], "blocked")
self.assertFalse(report["merge_allowed"])
class TestStdoutIsolation(unittest.TestCase):
"""Regression test for #178: tests must not close or corrupt stdout/stderr
(prevents need for junitxml workaround in full suite runs and review validation).
"""
def test_stdout_remains_usable(self):
"""After typical test activity (mocks, redirects, prints from mains), stdout should be usable."""
# Verify not closed
self.assertFalse(getattr(sys.stdout, "closed", False))
# Should be able to write (even if captured by pytest)
try:
sys.stdout.write("")
sys.stdout.flush()
except Exception as exc:
self.fail(f"stdout write failed after test activity: {exc}")
def test_stderr_remains_usable(self):
self.assertFalse(getattr(sys.stderr, "closed", False))
try:
sys.stderr.write("")
sys.stderr.flush()
except Exception as exc:
self.fail(f"stderr write failed: {exc}")
class TestRepoNameDisambiguation(unittest.TestCase):
"""Harness assertions for repo name disambiguation (new blind-review hardening).
"MCP Gitea tool" etc. must resolve to Gitea-Tools, not silently default to
mcp-control-plane. "open PRs" or ambiguous must check both. Single-repo
zero-result must not hide PRs in the other configured repo.
"""
CONFIGURED = [
"Scaled-Tech-Consulting/Gitea-Tools",
"Scaled-Tech-Consulting/mcp-control-plane",
]
def test_gitea_tools_aliases_resolve_to_gitea_tools_only(self):
for ref in [
"MCP Gitea tool",
"gitea tool",
"Gitea-Tools",
"gitea mcp tool",
"gitea-tools repo",
]:
result = resolve_repos_from_user_reference(ref, self.CONFIGURED)
self.assertEqual(result, ["Scaled-Tech-Consulting/Gitea-Tools"])
def test_mcp_control_plane_alias_resolves_only_to_it(self):
result = resolve_repos_from_user_reference(
"mcp-control-plane", self.CONFIGURED
)
self.assertEqual(result, ["Scaled-Tech-Consulting/mcp-control-plane"])
def test_ambiguous_or_empty_defaults_to_both(self):
self.assertEqual(
resolve_repos_from_user_reference("open PRs", self.CONFIGURED),
self.CONFIGURED,
)
self.assertEqual(
resolve_repos_from_user_reference("", self.CONFIGURED),
self.CONFIGURED,
)
self.assertEqual(
resolve_repos_from_user_reference("review the queue", self.CONFIGURED),
self.CONFIGURED,
)
def test_single_repo_zero_result_does_not_hide_other(self):
# Simulate a run that only inventoried mcp because of bad alias resolution.
# The inventory must still require Gitea-Tools to claim "no open PRs".
mcp_only_reports = [
{
"repo": "Scaled-Tech-Consulting/mcp-control-plane",
"state_filter": "open",
"pagination_complete": True,
"open_pr_count": 0,
}
]
result = assess_inventory_completeness(
repo_reports=mcp_only_reports,
required_repos=self.CONFIGURED,
)
self.assertFalse(result["complete"])
self.assertTrue(
any("Gitea-Tools" in r for r in result["reasons"])
)
def test_full_inventory_of_both_is_required_for_exhaustive_claim(self):
both_reports = [
{
"repo": "Scaled-Tech-Consulting/Gitea-Tools",
"state_filter": "open",
"pagination_complete": True,
"open_pr_count": 1,
},
{
"repo": "Scaled-Tech-Consulting/mcp-control-plane",
"state_filter": "open",
"pagination_complete": True,
"open_pr_count": 0,
},
]
result = assess_inventory_completeness(
repo_reports=both_reports, required_repos=self.CONFIGURED
)
self.assertTrue(result["complete"])
self.assertTrue(result["can_claim_exhaustive"])
class TestControllerHandoff(unittest.TestCase):
"""Issue #182: final reports must end with a Controller Handoff."""
@@ -479,6 +710,7 @@ class TestControllerHandoff(unittest.TestCase):
"- Files changed: review_proofs.py",
"- Validation: 700 passed, 6 skipped",
"- Mutations: one PR opened",
"- Workspace mutations: none",
"- Current status: PR open",
"- Blockers: none",
"- Next: review PR #999",
@@ -544,6 +776,40 @@ class TestControllerHandoff(unittest.TestCase):
self.assertEqual(result["verdict"], "incomplete")
self.assertIn("No review/merge confirmation", result["missing_fields"])
def test_author_role_rejects_equivalent_or_multiple_issues(self):
# 1. equivalent reference blocked
incomplete_eq = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: Issue #194 / #196 equivalent",
"- Claim/comment status: comment-claimed",
"- PR number opened: #999",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_eq, role="author")
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("Selected issue", res["missing_fields"])
# 2. multiple issues blocked
incomplete_multi = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: #194, #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: #999",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_multi, role="author")
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("Selected issue", res["missing_fields"])
def test_author_role_rejects_fuzzy_pr_number(self):
incomplete_pr = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: PR #203 / #204 equivalent",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_pr, role="author")
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("PR number opened", res["missing_fields"])
def test_inventory_role_requires_inventory_fields(self):
complete = self.BASE_HANDOFF + "\n" + "\n".join([
"- Repositories checked: Gitea-Tools, mcp-control-plane",
@@ -565,6 +831,110 @@ class TestControllerHandoff(unittest.TestCase):
self.assertIn("assess_controller_handoff", skill)
self.assertIn("issue #182", skill)
def test_handoff_rejects_none_workspace_mutations_when_local_edits_exist(self):
# 1. Workspace mutations: none is rejected when local_edits is True
incomplete_eq = self.BASE_HANDOFF + "\n" + "\n".join([
"- Selected issue: #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: #203",
"- No review/merge: confirmed",
])
res = assess_controller_handoff(incomplete_eq, role="author", local_edits=True)
self.assertEqual(res["verdict"], "incomplete")
self.assertIn("Workspace mutations", res["missing_fields"])
# 2. Workspace mutations: edited files is allowed when local_edits is True
complete_eq = self.BASE_HANDOFF.replace("- Workspace mutations: none", "- Workspace mutations: edited review_proofs.py") + "\n" + "\n".join([
"- Selected issue: #196",
"- Claim/comment status: comment-claimed",
"- PR number opened: #203",
"- No review/merge: confirmed",
])
res2 = assess_controller_handoff(complete_eq, role="author", local_edits=True)
self.assertEqual(res2["verdict"], "complete")
class TestPRInventoryTrustGate(unittest.TestCase):
"""Issue #194: unit tests for the PR inventory trust gate."""
def setUp(self):
self.profile = {
"profile_name": "prgs-reviewer",
"allowed_operations": ["read", "gitea.read", "gitea.pr.approve"],
}
self.local_url = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
def test_trusted_nonempty(self):
res = pr_inventory_trust_gate([{"number": 1}])
self.assertEqual(res["status"], "trusted_nonempty")
self.assertFalse(res["corroborated"])
def test_inventory_error_none_or_not_list(self):
self.assertEqual(pr_inventory_trust_gate(None)["status"], "inventory_error")
self.assertEqual(pr_inventory_trust_gate("not a list")["status"], "inventory_error")
def test_untrusted_empty_no_pagination_or_corroboration(self):
res = pr_inventory_trust_gate(
[], remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
state="open", authenticated_profile=self.profile,
local_remote_url=self.local_url, user_context=None,
corroboration_open_pr_counter=None, has_finality_metadata=False
)
self.assertEqual(res["status"], "untrusted_empty")
self.assertIn("pagination finality not proven and open_pr_counter corroboration is missing or non-zero", res["reasons"])
def test_untrusted_empty_profile_permission_mismatch(self):
bad_profile = {"profile_name": "prgs-bad", "allowed_operations": ["write"]}
res = pr_inventory_trust_gate(
[], remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
state="open", authenticated_profile=bad_profile,
local_remote_url=self.local_url, user_context=None,
corroboration_open_pr_counter=0, has_finality_metadata=False
)
self.assertEqual(res["status"], "untrusted_empty")
self.assertIn("authenticated profile lacks read permissions", res["reasons"])
def test_untrusted_empty_remote_url_mismatch(self):
bad_url = "https://gitea.prgs.cc/other-org/other-repo.git"
res = pr_inventory_trust_gate(
[], remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
state="open", authenticated_profile=self.profile,
local_remote_url=bad_url, user_context=None,
corroboration_open_pr_counter=0, has_finality_metadata=False
)
self.assertEqual(res["status"], "untrusted_empty")
self.assertIn("local remote URL does not match target repository 'Scaled-Tech-Consulting/Gitea-Tools'", res["reasons"])
def test_untrusted_empty_user_context_indicates_prs(self):
res = pr_inventory_trust_gate(
[], remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
state="open", authenticated_profile=self.profile,
local_remote_url=self.local_url, user_context="please check open PR #181",
corroboration_open_pr_counter=0, has_finality_metadata=False
)
self.assertEqual(res["status"], "untrusted_empty")
self.assertTrue(any("user context indicates open PRs should exist" in r for r in res["reasons"]))
def test_trusted_empty_with_corroboration(self):
res = pr_inventory_trust_gate(
[], remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
state="open", authenticated_profile=self.profile,
local_remote_url=self.local_url, user_context=None,
corroboration_open_pr_counter=0, has_finality_metadata=False
)
self.assertEqual(res["status"], "trusted_empty")
self.assertTrue(res["corroborated"])
def test_trusted_empty_with_finality_metadata(self):
res = pr_inventory_trust_gate(
[], remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools",
state="open", authenticated_profile=self.profile,
local_remote_url=self.local_url, user_context=None,
corroboration_open_pr_counter=None, has_finality_metadata=True
)
self.assertEqual(res["status"], "trusted_empty")
self.assertTrue(res["corroborated"])
if __name__ == "__main__":
unittest.main()
+33 -5
View File
@@ -14,11 +14,39 @@ BRANCHES = REPO / "branches"
def run(script, *args):
proc = subprocess.run(
["bash", str(SCRIPTS / script), *args],
capture_output=True, text=True, cwd=str(REPO),
)
return proc.returncode, proc.stdout, proc.stderr
branch = None
for arg in args:
if not arg.startswith("-"):
branch = arg
break
lock_file = Path("/tmp/gitea_issue_lock.json")
created_lock = False
if script == "worktree-start" and branch:
import re
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_file.write_text(json.dumps({
"issue_number": issue_num,
"branch_name": branch,
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"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),
)
return proc.returncode, proc.stdout, proc.stderr
finally:
if created_lock and lock_file.exists():
lock_file.unlink()
class TestWorktreeStart(unittest.TestCase):