Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f2db4d6a | ||
|
|
9b5f03fa40 | ||
|
|
1a1e679246 | ||
|
|
9fde4f3e76 | ||
|
|
af7131abf1 | ||
|
|
71ccbca10f |
@@ -457,6 +457,40 @@ def _rule_reviewer_git_fetch_readonly(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_reviewer_validation_failure_history(
|
||||||
|
report_text: str,
|
||||||
|
*,
|
||||||
|
validation_session: dict | None = None,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
from reviewer_validation_failure_history import (
|
||||||
|
assess_validation_failure_history_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
session = validation_session or {}
|
||||||
|
observed = session.get("observed_failures") or []
|
||||||
|
if not observed:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = assess_validation_failure_history_report(
|
||||||
|
report_text,
|
||||||
|
validation_session=session,
|
||||||
|
)
|
||||||
|
if result.get("proven"):
|
||||||
|
return []
|
||||||
|
severity = "block" if result.get("violations") else "downgrade"
|
||||||
|
return [
|
||||||
|
validator_finding(
|
||||||
|
"reviewer.validation_failure_history",
|
||||||
|
severity,
|
||||||
|
"Validation failure history",
|
||||||
|
reason,
|
||||||
|
result.get("safe_next_action")
|
||||||
|
or "document every observed validation failure before claiming pass",
|
||||||
|
)
|
||||||
|
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
|
||||||
text = report_text or ""
|
text = report_text or ""
|
||||||
if not _BARE_PYTEST_RE.search(text):
|
if not _BARE_PYTEST_RE.search(text):
|
||||||
@@ -879,6 +913,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
|
|||||||
_rule_reviewer_mutation_categories,
|
_rule_reviewer_mutation_categories,
|
||||||
_rule_reviewer_git_fetch_readonly,
|
_rule_reviewer_git_fetch_readonly,
|
||||||
_rule_reviewer_validation_command,
|
_rule_reviewer_validation_command,
|
||||||
|
_rule_reviewer_validation_failure_history,
|
||||||
_rule_reviewer_validation_structured,
|
_rule_reviewer_validation_structured,
|
||||||
_rule_reviewer_linked_issue,
|
_rule_reviewer_linked_issue,
|
||||||
_rule_reviewer_baseline_on_failure,
|
_rule_reviewer_baseline_on_failure,
|
||||||
@@ -986,6 +1021,7 @@ def assess_final_report_validator(
|
|||||||
local_edits: bool = False,
|
local_edits: bool = False,
|
||||||
issue_filing_lock: dict | None = None,
|
issue_filing_lock: dict | None = None,
|
||||||
session_pr_opened: bool = False,
|
session_pr_opened: bool = False,
|
||||||
|
validation_session: dict | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate final-report text against task-specific proof rules (#327).
|
"""Validate final-report text against task-specific proof rules (#327).
|
||||||
|
|
||||||
@@ -1041,6 +1077,7 @@ def assess_final_report_validator(
|
|||||||
"mutations_observed": mutations_observed,
|
"mutations_observed": mutations_observed,
|
||||||
"local_edits": local_edits,
|
"local_edits": local_edits,
|
||||||
"session_pr_opened": session_pr_opened,
|
"session_pr_opened": session_pr_opened,
|
||||||
|
"validation_session": validation_session,
|
||||||
}
|
}
|
||||||
|
|
||||||
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
|
||||||
|
|||||||
+21
-3
@@ -1245,7 +1245,7 @@ def gitea_create_pr(
|
|||||||
)
|
)
|
||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote, worktree_path=worktree_path)
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
|
||||||
# ── Issue Lock Validation (Issue #194 / #196) ──
|
# ── Issue Lock Validation (Issue #194 / #196) ──
|
||||||
@@ -3464,16 +3464,32 @@ def gitea_delete_branch(
|
|||||||
repo: Override the repository name.
|
repo: Override the repository name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with 'success' and 'message'.
|
dict with 'success' and 'message'; on a permission block,
|
||||||
|
'success'/'performed' False, 'reasons', and a structured
|
||||||
|
'permission_report' (#142) with no API call made.
|
||||||
"""
|
"""
|
||||||
|
gate_reasons = _profile_operation_gate("gitea.branch.delete")
|
||||||
|
if gate_reasons:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"required_permission": "gitea.branch.delete",
|
||||||
|
"reasons": gate_reasons,
|
||||||
|
"permission_report": _permission_block_report("gitea.branch.delete"),
|
||||||
|
}
|
||||||
|
|
||||||
verify_preflight_purity(remote)
|
verify_preflight_purity(remote)
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
encoded_branch = urllib.parse.quote(branch, safe="")
|
encoded_branch = urllib.parse.quote(branch, safe="")
|
||||||
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
|
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
|
||||||
|
request_metadata = {
|
||||||
|
"branch": branch,
|
||||||
|
"required_permission": "gitea.branch.delete",
|
||||||
|
}
|
||||||
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
|
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
|
||||||
target_branch=branch, request_metadata={"branch": branch}):
|
target_branch=branch, request_metadata=request_metadata):
|
||||||
api_request("DELETE", url, auth)
|
api_request("DELETE", url, auth)
|
||||||
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
|
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
|
||||||
|
|
||||||
@@ -5510,6 +5526,7 @@ def gitea_validate_review_final_report(
|
|||||||
action_log: list[dict] | None = None,
|
action_log: list[dict] | None = None,
|
||||||
mutations_observed: bool = False,
|
mutations_observed: bool = False,
|
||||||
local_edits: bool = False,
|
local_edits: bool = False,
|
||||||
|
validation_session: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Read-only: machine-validate reviewer final report schema before output (#391).
|
"""Read-only: machine-validate reviewer final report schema before output (#391).
|
||||||
|
|
||||||
@@ -5527,6 +5544,7 @@ def gitea_validate_review_final_report(
|
|||||||
action_log=action_log,
|
action_log=action_log,
|
||||||
mutations_observed=mutations_observed,
|
mutations_observed=mutations_observed,
|
||||||
local_edits=local_edits,
|
local_edits=local_edits,
|
||||||
|
validation_session=validation_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ def assess_post_merge_cleanup_proof(
|
|||||||
|
|
||||||
if not remote_delete and not worktree_remove:
|
if not remote_delete and not worktree_remove:
|
||||||
cleanup_mutations = re.search(
|
cleanup_mutations = re.search(
|
||||||
r"cleanup mutations\s*:\s*(?!none\b)(.+)",
|
r"cleanup mutations\s*:\s*(?!none\b)\S",
|
||||||
text,
|
text,
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ def assess_review_final_report_schema(
|
|||||||
action_log: list[dict] | None = None,
|
action_log: list[dict] | None = None,
|
||||||
mutations_observed: bool = False,
|
mutations_observed: bool = False,
|
||||||
local_edits: bool = False,
|
local_edits: bool = False,
|
||||||
|
validation_session: dict | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate reviewer final report text before session completion (#391)."""
|
"""Validate reviewer final report text before session completion (#391)."""
|
||||||
base = assess_final_report_validator(
|
base = assess_final_report_validator(
|
||||||
@@ -312,6 +313,7 @@ def assess_review_final_report_schema(
|
|||||||
action_log=action_log,
|
action_log=action_log,
|
||||||
mutations_observed=mutations_observed,
|
mutations_observed=mutations_observed,
|
||||||
local_edits=local_edits,
|
local_edits=local_edits,
|
||||||
|
validation_session=validation_session,
|
||||||
)
|
)
|
||||||
extra: list[dict[str, str]] = []
|
extra: list[dict[str, str]] = []
|
||||||
for rule in _SCHEMA_RULES:
|
for rule in _SCHEMA_RULES:
|
||||||
|
|||||||
@@ -5503,6 +5503,15 @@ def assess_validation_integrity_report(report_text, **kwargs):
|
|||||||
return _assess(report_text, **kwargs)
|
return _assess(report_text, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_validation_failure_history_report(report_text, **kwargs):
|
||||||
|
"""#396: final reports must account for transient validation failures."""
|
||||||
|
from reviewer_validation_failure_history import (
|
||||||
|
assess_validation_failure_history_report as _assess,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _assess(report_text, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def assess_already_landed_classification_report(report_text, **kwargs):
|
def assess_already_landed_classification_report(report_text, **kwargs):
|
||||||
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
|
||||||
from reviewer_already_landed_classification import (
|
from reviewer_already_landed_classification import (
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Transient validation failure history verifier (#396).
|
||||||
|
|
||||||
|
Reviewer sessions may observe validation failures that later pass on rerun.
|
||||||
|
Final reports must document every failure observed during the session, not
|
||||||
|
only the last passing result.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_SECTION_RE = re.compile(
|
||||||
|
r"validation failure history",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_FAILURE_ENTRY_RE = re.compile(
|
||||||
|
r"(?:failure\s*(?:#|entry)?\s*\d+|validation failure)\s*:",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_COMMAND_RE = re.compile(
|
||||||
|
r"(?:command|validation command)\s*:\s*(.+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_FAILING_TEST_RE = re.compile(
|
||||||
|
r"(?:failing test|failure|error)\s*:\s*(.+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_CAUSE_RE = re.compile(
|
||||||
|
r"(?:suspected cause|cause)\s*:\s*(.+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_REPRODUCED_RE = re.compile(
|
||||||
|
r"(?:reproduced|reproduces)\s*:\s*(yes|no|unknown)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_BASELINE_RE = re.compile(
|
||||||
|
r"(?:on baseline master|baseline master|exists on baseline)\s*:\s*(yes|no|unknown|not checked)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PR_CAUSED_RE = re.compile(
|
||||||
|
r"(?:pr[- ]caused|pr caused)\s*:\s*(yes|no|unknown|not proven)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_TRANSIENT_STATUS_RE = re.compile(
|
||||||
|
r"(?:passed after transient failure investigation|transient failure investigation)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PLAIN_PASS_RE = re.compile(
|
||||||
|
r"validation\s*:\s*(?:pass|passed|strong|ok|green)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_ENV_CLEANUP_RE = re.compile(
|
||||||
|
r"(?:environmental cleanup|state cleaned|what changed between runs)\s*:",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_UNKNOWN_CAUSE_RE = re.compile(
|
||||||
|
r"(?:suspected cause|cause)\s*:\s*(?:unknown|unexplained|not determined)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_documented_in_text(text: str, failure: dict[str, Any]) -> bool:
|
||||||
|
"""Return True when *failure* appears documented in free-form report text."""
|
||||||
|
command = (failure.get("command") or "").strip()
|
||||||
|
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
|
||||||
|
if command and command not in text:
|
||||||
|
return False
|
||||||
|
if failing and failing not in text:
|
||||||
|
return False
|
||||||
|
return bool(command or failing)
|
||||||
|
|
||||||
|
|
||||||
|
def _section_has_structured_fields(text: str) -> bool:
|
||||||
|
if not _SECTION_RE.search(text):
|
||||||
|
return False
|
||||||
|
section_start = _SECTION_RE.search(text).start()
|
||||||
|
section = text[section_start:]
|
||||||
|
has_command = bool(_COMMAND_RE.search(section))
|
||||||
|
has_failure = bool(_FAILING_TEST_RE.search(section))
|
||||||
|
return has_command and has_failure
|
||||||
|
|
||||||
|
|
||||||
|
def assess_validation_failure_history_report(
|
||||||
|
report_text: str,
|
||||||
|
*,
|
||||||
|
validation_session: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Require final reports to account for transient validation failures (#396)."""
|
||||||
|
text = report_text or ""
|
||||||
|
session = dict(validation_session or {})
|
||||||
|
reasons: list[str] = []
|
||||||
|
violations: list[str] = []
|
||||||
|
|
||||||
|
observed = list(session.get("observed_failures") or [])
|
||||||
|
final_status = (session.get("final_validation_status") or "").strip().lower()
|
||||||
|
cause_unknown = any(
|
||||||
|
(f.get("suspected_cause") or "").strip().lower() in {
|
||||||
|
"unknown", "unexplained", "not determined", ""
|
||||||
|
}
|
||||||
|
and not (f.get("pr_caused") or "").strip().lower() in {"yes", "no"}
|
||||||
|
for f in observed
|
||||||
|
) or bool(session.get("cause_unknown"))
|
||||||
|
|
||||||
|
if not observed:
|
||||||
|
return {
|
||||||
|
"proven": True,
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"violations": [],
|
||||||
|
"observed_failure_count": 0,
|
||||||
|
"safe_next_action": "proceed",
|
||||||
|
}
|
||||||
|
|
||||||
|
documented = _section_has_structured_fields(text)
|
||||||
|
if not documented:
|
||||||
|
for failure in observed:
|
||||||
|
if _failure_documented_in_text(text, failure):
|
||||||
|
documented = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not documented:
|
||||||
|
violations.append(
|
||||||
|
"session observed validation failure(s) but final report omits "
|
||||||
|
"Validation failure history"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"every validation failure observed during the session must appear "
|
||||||
|
"in a Validation failure history section"
|
||||||
|
)
|
||||||
|
|
||||||
|
if documented and not _SECTION_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"failure details must appear under an explicit "
|
||||||
|
"'Validation failure history' heading"
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, failure in enumerate(observed, start=1):
|
||||||
|
prefix = f"failure #{idx}"
|
||||||
|
if not _failure_documented_in_text(text, failure) and documented:
|
||||||
|
reasons.append(
|
||||||
|
f"{prefix}: command and failing test/error not documented in report"
|
||||||
|
)
|
||||||
|
command = (failure.get("command") or "").strip()
|
||||||
|
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
|
||||||
|
if not command:
|
||||||
|
reasons.append(f"{prefix}: missing command in session failure record")
|
||||||
|
if not failing:
|
||||||
|
reasons.append(
|
||||||
|
f"{prefix}: missing failing test or error in session failure record"
|
||||||
|
)
|
||||||
|
|
||||||
|
plain_pass = bool(_PLAIN_PASS_RE.search(text))
|
||||||
|
transient_wording = bool(_TRANSIENT_STATUS_RE.search(text))
|
||||||
|
final_passed = final_status in {
|
||||||
|
"passed",
|
||||||
|
"pass",
|
||||||
|
"passed_after_transient_failure_investigation",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (final_passed or plain_pass) and observed:
|
||||||
|
if cause_unknown and not transient_wording:
|
||||||
|
violations.append(
|
||||||
|
"unknown transient failure cause cannot be erased as plain 'passed'"
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
"when cause is unknown, use status "
|
||||||
|
"'passed after transient failure investigation'"
|
||||||
|
)
|
||||||
|
elif not transient_wording and final_status != "passed_after_transient_failure_investigation":
|
||||||
|
if not _ENV_CLEANUP_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"later passing rerun must document what changed between runs "
|
||||||
|
"or environmental cleanup performed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.get("environmental_contamination") and not _ENV_CLEANUP_RE.search(text):
|
||||||
|
reasons.append(
|
||||||
|
"environmental /tmp state contamination must document cleanup or "
|
||||||
|
"what changed before the passing rerun"
|
||||||
|
)
|
||||||
|
|
||||||
|
proven = not reasons and not violations
|
||||||
|
return {
|
||||||
|
"proven": proven,
|
||||||
|
"block": bool(violations) or not proven,
|
||||||
|
"reasons": reasons,
|
||||||
|
"violations": violations,
|
||||||
|
"observed_failure_count": len(observed),
|
||||||
|
"documented": documented,
|
||||||
|
"safe_next_action": (
|
||||||
|
"add Validation failure history with command, failing test, cause, "
|
||||||
|
"reproduction, baseline comparison, and PR-caused evidence; use "
|
||||||
|
"'passed after transient failure investigation' when appropriate"
|
||||||
|
if not proven
|
||||||
|
else "proceed"
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -538,6 +538,36 @@ Official validation integrity status must be one of:
|
|||||||
|
|
||||||
Do not invent a softer status.
|
Do not invent a softer status.
|
||||||
|
|
||||||
|
## 21A. Validation failure history rule
|
||||||
|
|
||||||
|
Every validation failure observed during the review session must appear in the
|
||||||
|
final report, even if a later rerun passes.
|
||||||
|
|
||||||
|
Before claiming `Validation: passed`, list every failed validation command from
|
||||||
|
the session under `Validation failure history`.
|
||||||
|
|
||||||
|
For each failure, report:
|
||||||
|
|
||||||
|
* command
|
||||||
|
* failing test or error
|
||||||
|
* suspected cause
|
||||||
|
* whether it reproduced
|
||||||
|
* whether it exists on baseline master
|
||||||
|
* evidence for whether it is or is not PR-caused
|
||||||
|
|
||||||
|
If a later rerun passes, also report:
|
||||||
|
|
||||||
|
* what changed between runs
|
||||||
|
* whether environmental state was cleaned (for example `/tmp` lock files)
|
||||||
|
* why the pass is trustworthy
|
||||||
|
|
||||||
|
If the cause is unknown, do not erase the earlier failure with plain
|
||||||
|
`Validation: passed`. Use a status such as
|
||||||
|
`passed after transient failure investigation`.
|
||||||
|
|
||||||
|
`gitea_validate_review_final_report` rejects reports that omit known earlier
|
||||||
|
validation failures when `validation_session.observed_failures` is supplied.
|
||||||
|
|
||||||
## 22. Baseline validation rule
|
## 22. Baseline validation rule
|
||||||
|
|
||||||
Do not run tests in the main checkout.
|
Do not run tests in the main checkout.
|
||||||
@@ -1130,6 +1160,7 @@ Controller Handoff:
|
|||||||
* Baseline worktree path:
|
* Baseline worktree path:
|
||||||
* Files reviewed:
|
* Files reviewed:
|
||||||
* Validation:
|
* Validation:
|
||||||
|
* Validation failure history:
|
||||||
* Official validation integrity status:
|
* Official validation integrity status:
|
||||||
* Terminal review mutation:
|
* Terminal review mutation:
|
||||||
* Review decision:
|
* Review decision:
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Tests for gitea_delete_branch capability gate (Issue #408).
|
||||||
|
|
||||||
|
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
|
||||||
|
without it the delete fails closed (no preflight, no auth lookup, no API call,
|
||||||
|
structured permission report). With it, deletion proceeds through existing
|
||||||
|
preflight and audit unchanged.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import mcp_server
|
||||||
|
from mcp_server import gitea_delete_branch
|
||||||
|
|
||||||
|
FAKE_AUTH = "token fake"
|
||||||
|
|
||||||
|
AUTHOR_NO_DELETE = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
|
||||||
|
"gitea.branch.push", "gitea.issue.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
|
"audit_label": "prgs-author",
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTHOR_WITH_DELETE = {
|
||||||
|
"profile_name": "prgs-author-deleter",
|
||||||
|
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"] + [
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
|
||||||
|
"audit_label": "prgs-author-deleter",
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"version": 2,
|
||||||
|
"contexts": {
|
||||||
|
"ctx": {
|
||||||
|
"enabled": True,
|
||||||
|
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"author-no-delete": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "author",
|
||||||
|
"username": "author-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
|
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"execution_profile": "author-no-delete",
|
||||||
|
},
|
||||||
|
"author-with-delete": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "author",
|
||||||
|
"username": "deleter-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
|
||||||
|
"allowed_operations": AUTHOR_WITH_DELETE["allowed_operations"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"execution_profile": "author-with-delete",
|
||||||
|
},
|
||||||
|
"reviewer-profile": {
|
||||||
|
"enabled": True,
|
||||||
|
"context": "ctx",
|
||||||
|
"role": "reviewer",
|
||||||
|
"username": "reviewer-user",
|
||||||
|
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.pr.review", "gitea.pr.merge",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.branch.delete", "gitea.branch.push", "gitea.pr.create",
|
||||||
|
],
|
||||||
|
"execution_profile": "reviewer-profile",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteBranchToolGate(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._preflight_snapshot = (
|
||||||
|
mcp_server._preflight_whoami_called,
|
||||||
|
mcp_server._preflight_capability_called,
|
||||||
|
)
|
||||||
|
mcp_server._preflight_whoami_called = False
|
||||||
|
mcp_server._preflight_capability_called = False
|
||||||
|
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
|
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||||
|
"repo": "Example-Repo"},
|
||||||
|
})
|
||||||
|
self._remotes.start()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
patch("gitea_config.load_config", return_value={}).start()
|
||||||
|
patch(
|
||||||
|
"gitea_config.is_runtime_switching_enabled", return_value=False
|
||||||
|
).start()
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||||
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
|
self.mock_auth = patch(
|
||||||
|
"mcp_server.get_auth_header", return_value=FAKE_AUTH
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
|
||||||
|
self._preflight_snapshot
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_profile(self, profile):
|
||||||
|
patch("mcp_server.get_profile", return_value=profile).start()
|
||||||
|
|
||||||
|
def test_blocked_without_delete_capability(self):
|
||||||
|
self._set_profile(AUTHOR_NO_DELETE)
|
||||||
|
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertFalse(res["performed"])
|
||||||
|
self.assertEqual(res["required_permission"], "gitea.branch.delete")
|
||||||
|
self.assertTrue(res["reasons"])
|
||||||
|
self.assertEqual(
|
||||||
|
res["permission_report"]["missing_permission"],
|
||||||
|
"gitea.branch.delete",
|
||||||
|
)
|
||||||
|
self.mock_api.assert_not_called()
|
||||||
|
self.mock_auth.assert_not_called()
|
||||||
|
|
||||||
|
def test_allowed_delete_proceeds(self):
|
||||||
|
self._set_profile(AUTHOR_WITH_DELETE)
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||||
|
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertIn("deleted", res["message"])
|
||||||
|
delete_calls = [
|
||||||
|
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||||
|
]
|
||||||
|
self.assertTrue(delete_calls)
|
||||||
|
|
||||||
|
def test_allowed_delete_audited_with_capability_proof(self):
|
||||||
|
self._set_profile(AUTHOR_WITH_DELETE)
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=True).start()
|
||||||
|
mock_write = patch("gitea_audit.write_event").start()
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||||
|
self.mock_api.return_value = {}
|
||||||
|
gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
|
mock_write.assert_called()
|
||||||
|
event = mock_write.call_args[0][0]
|
||||||
|
self.assertEqual(event["action"], "delete_branch")
|
||||||
|
self.assertEqual(
|
||||||
|
event["request_metadata"]["required_permission"],
|
||||||
|
"gitea.branch.delete",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteBranchResolverParity(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._remotes = patch.dict(mcp_server.REMOTES, {
|
||||||
|
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
|
||||||
|
"repo": "Example-Repo"},
|
||||||
|
})
|
||||||
|
self._remotes.start()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.config_path = os.path.join(self._dir.name, "profiles.json")
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(CONFIG))
|
||||||
|
patch(
|
||||||
|
"gitea_config.is_runtime_switching_enabled", return_value=False
|
||||||
|
).start()
|
||||||
|
patch("gitea_audit.audit_enabled", return_value=False).start()
|
||||||
|
self.mock_api = patch("mcp_server.api_request").start()
|
||||||
|
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
self._dir.cleanup()
|
||||||
|
|
||||||
|
def _env(self, profile: str) -> dict:
|
||||||
|
return {
|
||||||
|
"GITEA_MCP_CONFIG": self.config_path,
|
||||||
|
"GITEA_MCP_PROFILE": profile,
|
||||||
|
"GITEA_TOKEN_AUTHOR": "author-pass",
|
||||||
|
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_reviewer_resolver_denial_blocks_raw_tool(self):
|
||||||
|
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
|
||||||
|
resolve = mcp_server.gitea_resolve_task_capability(
|
||||||
|
task="delete_branch", remote="prgs")
|
||||||
|
self.assertFalse(resolve["allowed_in_current_session"])
|
||||||
|
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
|
||||||
|
self.assertFalse(res["success"])
|
||||||
|
self.assertEqual(
|
||||||
|
res["permission_report"]["missing_permission"],
|
||||||
|
resolve["required_operation_permission"],
|
||||||
|
)
|
||||||
|
delete_calls = [
|
||||||
|
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
|
||||||
|
]
|
||||||
|
self.assertFalse(delete_calls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -207,6 +207,12 @@ def test_validation_integrity_verifier_exported():
|
|||||||
assert callable(assess_validation_integrity_report)
|
assert callable(assess_validation_integrity_report)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_failure_history_verifier_exported():
|
||||||
|
from review_proofs import assess_validation_failure_history_report
|
||||||
|
|
||||||
|
assert callable(assess_validation_failure_history_report)
|
||||||
|
|
||||||
|
|
||||||
def test_prior_blocker_skip_verifier_exported():
|
def test_prior_blocker_skip_verifier_exported():
|
||||||
from review_proofs import assess_prior_blocker_skip_proof
|
from review_proofs import assess_prior_blocker_skip_proof
|
||||||
|
|
||||||
|
|||||||
@@ -1005,9 +1005,19 @@ class TestReviewPR(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestDeleteBranch(unittest.TestCase):
|
class TestDeleteBranch(unittest.TestCase):
|
||||||
|
|
||||||
|
DELETE_PROFILE = {
|
||||||
|
"profile_name": "test-deleter",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.branch.delete"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"audit_label": "test-deleter",
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch("mcp_server.get_profile", return_value=DELETE_PROFILE)
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_delete_branch(self, _auth, mock_api):
|
def test_delete_branch(self, _auth, mock_api, _profile):
|
||||||
|
mcp_server.record_preflight_check("whoami")
|
||||||
|
mcp_server.record_preflight_check("capability", resolved_role="author")
|
||||||
mock_api.return_value = {}
|
mock_api.return_value = {}
|
||||||
result = gitea_delete_branch(branch="feat/branch")
|
result = gitea_delete_branch(branch="feat/branch")
|
||||||
self.assertTrue(result["success"])
|
self.assertTrue(result["success"])
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Tests for transient validation failure history verifier (#396)."""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from final_report_validator import assess_final_report_validator # noqa: E402
|
||||||
|
from reviewer_validation_failure_history import ( # noqa: E402
|
||||||
|
assess_validation_failure_history_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_history_report() -> str:
|
||||||
|
return "\n".join([
|
||||||
|
"Validation failure history",
|
||||||
|
"Failure #1:",
|
||||||
|
"Command: venv/bin/python -m pytest tests/test_worktrees.py -q",
|
||||||
|
"Failing test: tests/test_worktrees.py::TestWorktreeStart::test_rejects_untraceable_branches",
|
||||||
|
"Suspected cause: stale /tmp/gitea_issue_lock.json from prior session",
|
||||||
|
"Reproduced: no",
|
||||||
|
"On baseline master: no",
|
||||||
|
"PR-caused: no",
|
||||||
|
"What changed between runs: removed /tmp/gitea_issue_lock.json",
|
||||||
|
"Environmental cleanup: lock file removed before rerun",
|
||||||
|
"Validation: passed after transient failure investigation",
|
||||||
|
"Final validation command: venv/bin/python -m pytest tests/ -q",
|
||||||
|
"Final result: 1497 passed, 6 skipped",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationFailureHistory(unittest.TestCase):
|
||||||
|
def test_no_failures_observed_passes(self):
|
||||||
|
result = assess_validation_failure_history_report(
|
||||||
|
"Validation: passed",
|
||||||
|
validation_session={"observed_failures": []},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"])
|
||||||
|
|
||||||
|
def test_omitted_failure_blocks(self):
|
||||||
|
result = assess_validation_failure_history_report(
|
||||||
|
"Validation: passed\n1497 passed, 6 skipped",
|
||||||
|
validation_session={
|
||||||
|
"observed_failures": [{
|
||||||
|
"command": "pytest tests/test_worktrees.py -q",
|
||||||
|
"failing_test": (
|
||||||
|
"tests/test_worktrees.py::TestWorktreeStart::"
|
||||||
|
"test_rejects_untraceable_branches"
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
"final_validation_status": "passed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"])
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_full_history_with_transient_investigation_passes(self):
|
||||||
|
result = assess_validation_failure_history_report(
|
||||||
|
_full_history_report(),
|
||||||
|
validation_session={
|
||||||
|
"observed_failures": [{
|
||||||
|
"command": (
|
||||||
|
"venv/bin/python -m pytest tests/test_worktrees.py -q"
|
||||||
|
),
|
||||||
|
"failing_test": (
|
||||||
|
"tests/test_worktrees.py::TestWorktreeStart::"
|
||||||
|
"test_rejects_untraceable_branches"
|
||||||
|
),
|
||||||
|
"suspected_cause": "stale /tmp/gitea_issue_lock.json",
|
||||||
|
"reproduced": "no",
|
||||||
|
"on_baseline_master": "no",
|
||||||
|
"pr_caused": "no",
|
||||||
|
}],
|
||||||
|
"final_validation_status": "passed_after_transient_failure_investigation",
|
||||||
|
"environmental_contamination": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result["reasons"])
|
||||||
|
|
||||||
|
def test_baseline_equivalent_failure_documented(self):
|
||||||
|
report = "\n".join([
|
||||||
|
"Validation failure history",
|
||||||
|
"Command: pytest tests/test_example.py -q",
|
||||||
|
"Failing test: tests/test_example.py::test_flaky",
|
||||||
|
"Suspected cause: pre-existing master failure",
|
||||||
|
"On baseline master: yes",
|
||||||
|
"PR-caused: no",
|
||||||
|
"What changed between runs: none; failure matches baseline",
|
||||||
|
"Validation: passed after transient failure investigation",
|
||||||
|
])
|
||||||
|
result = assess_validation_failure_history_report(
|
||||||
|
report,
|
||||||
|
validation_session={
|
||||||
|
"observed_failures": [{
|
||||||
|
"command": "pytest tests/test_example.py -q",
|
||||||
|
"failing_test": "tests/test_example.py::test_flaky",
|
||||||
|
"on_baseline_master": "yes",
|
||||||
|
"pr_caused": "no",
|
||||||
|
}],
|
||||||
|
"final_validation_status": "passed_after_transient_failure_investigation",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["proven"], result["reasons"])
|
||||||
|
|
||||||
|
def test_unknown_cause_plain_pass_blocks(self):
|
||||||
|
result = assess_validation_failure_history_report(
|
||||||
|
"Validation: passed",
|
||||||
|
validation_session={
|
||||||
|
"observed_failures": [{
|
||||||
|
"command": "pytest tests/ -q",
|
||||||
|
"failing_test": "tests/test_foo.py::test_bar",
|
||||||
|
"suspected_cause": "unknown",
|
||||||
|
}],
|
||||||
|
"final_validation_status": "passed",
|
||||||
|
"cause_unknown": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertFalse(result["proven"])
|
||||||
|
self.assertTrue(any("transient" in r.lower() for r in result["reasons"]))
|
||||||
|
|
||||||
|
def test_final_report_validator_rejects_omitted_failure(self):
|
||||||
|
result = assess_final_report_validator(
|
||||||
|
"Validation: passed",
|
||||||
|
"review_pr",
|
||||||
|
validation_session={
|
||||||
|
"observed_failures": [{
|
||||||
|
"command": "pytest tests/ -q",
|
||||||
|
"failing_test": "tests/test_foo.py::test_bar",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(result["blocked"] or result["downgraded"])
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
f.get("rule_id") == "reviewer.validation_failure_history"
|
||||||
|
for f in result.get("findings") or []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exported_from_review_proofs(self):
|
||||||
|
from review_proofs import assess_validation_failure_history_report as exported
|
||||||
|
|
||||||
|
self.assertTrue(callable(exported))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user