Add issue_acceptance_gate validation for Controller Issue Acceptance comments, final-report rules blocking merge-only issue completion claims, documentation/templates, and regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
300 lines
9.5 KiB
Python
300 lines
9.5 KiB
Python
"""Controller issue-acceptance gate helpers (#500).
|
|
|
|
Pure validation for controller acceptance comments and final-report claims
|
|
that an issue is complete. Does not post comments or close issues.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
ACCEPTANCE_HEADING = "controller issue acceptance"
|
|
|
|
REQUIRED_FIELDS = (
|
|
"STATE",
|
|
"WHO_IS_NEXT",
|
|
"NEXT_ACTION",
|
|
"NEXT_PROMPT",
|
|
"ISSUE",
|
|
"MERGED_PR",
|
|
"MERGE_COMMIT",
|
|
"ACCEPTANCE_CRITERIA_CHECKED",
|
|
"VALIDATION_REVIEWED",
|
|
"CONTROLLER_DECISION",
|
|
"WHY",
|
|
)
|
|
|
|
ACCEPTED_STATES = frozenset({"accepted"})
|
|
REJECTION_STATES = frozenset({
|
|
"more-work-required",
|
|
"more_work_required",
|
|
"needs-tests",
|
|
"needs_tests",
|
|
"needs-docs",
|
|
"needs_docs",
|
|
"needs-feature-enhancement",
|
|
"needs_feature_enhancement",
|
|
"needs-follow-up-issue",
|
|
"needs_follow_up_issue",
|
|
"blocked",
|
|
})
|
|
|
|
ALLOWED_NEXT_ACTORS = frozenset({
|
|
"controller",
|
|
"author",
|
|
"reviewer",
|
|
"merger",
|
|
"reconciler",
|
|
"user",
|
|
})
|
|
|
|
_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$")
|
|
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
|
_ISSUE_REF_RE = re.compile(r"#\d+")
|
|
_PR_REF_RE = re.compile(r"#\d+")
|
|
_CHECKED_ITEM_RE = re.compile(r"\[[xX]\]")
|
|
_UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]")
|
|
|
|
_CLAIMS_ISSUE_COMPLETE_RE = re.compile(
|
|
r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|"
|
|
r"\bissue\s+acceptance\s*:\s*accepted\b|"
|
|
r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_MERGE_ONLY_COMPLETE_RE = re.compile(
|
|
r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}"
|
|
r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
_CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile(
|
|
r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)",
|
|
re.IGNORECASE,
|
|
)
|
|
_PENDING_ACCEPTANCE_RE = re.compile(
|
|
r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def render_controller_acceptance_template() -> str:
|
|
"""Return the canonical controller issue-acceptance comment template."""
|
|
return """## Controller Issue Acceptance
|
|
|
|
STATE:
|
|
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
|
|
|
|
WHO_IS_NEXT:
|
|
<author | reviewer | merger | reconciler | controller | user>
|
|
|
|
NEXT_ACTION:
|
|
<one sentence>
|
|
|
|
NEXT_PROMPT:
|
|
<paste-ready prompt for the next LLM>
|
|
|
|
ISSUE:
|
|
#...
|
|
|
|
MERGED_PR:
|
|
#...
|
|
|
|
MERGE_COMMIT:
|
|
<40-character SHA>
|
|
|
|
ACCEPTANCE_CRITERIA_CHECKED:
|
|
- [x] ...
|
|
- [ ] ...
|
|
|
|
VALIDATION_REVIEWED:
|
|
<tests/proofs reviewed>
|
|
|
|
CONTROLLER_DECISION:
|
|
<accepted or rejected>
|
|
|
|
WHY:
|
|
<reasoning>
|
|
|
|
MISSING_WORK:
|
|
<none, or exact missing work>
|
|
|
|
FOLLOW_UP_ISSUES:
|
|
<none, or issue list to create>
|
|
|
|
BLOCKERS:
|
|
<none, or exact blockers>
|
|
|
|
LAST_UPDATED_BY:
|
|
<identity/profile/date>
|
|
"""
|
|
|
|
|
|
def extract_acceptance_fields(text: str | None) -> dict[str, str]:
|
|
"""Return upper-case labeled fields from a controller acceptance block."""
|
|
fields: dict[str, str] = {}
|
|
current_key: str | None = None
|
|
for line in (text or "").splitlines():
|
|
match = _FIELD_RE.match(line)
|
|
if match:
|
|
current_key = match.group(1).strip().upper().replace(" ", "_")
|
|
fields[current_key] = match.group(2).strip()
|
|
continue
|
|
stripped = line.strip()
|
|
if current_key and stripped:
|
|
existing = fields.get(current_key, "")
|
|
fields[current_key] = (
|
|
f"{existing}\n{stripped}" if existing else stripped
|
|
)
|
|
return fields
|
|
|
|
|
|
def contains_acceptance_block(text: str | None) -> bool:
|
|
return ACCEPTANCE_HEADING in (text or "").lower()
|
|
|
|
|
|
def _empty_or_placeholder(value: str | None) -> bool:
|
|
value = (value or "").strip().lower()
|
|
return not value or value in {"none", "n/a", "unknown", "tbd", "<...>", "..."}
|
|
|
|
|
|
def _normalize_state(value: str | None) -> str:
|
|
return (value or "").strip().lower().replace(" ", "_").replace("-", "_")
|
|
|
|
|
|
def validate_controller_acceptance_comment(text: str | None) -> dict:
|
|
"""Validate a controller issue-acceptance comment."""
|
|
body = text or ""
|
|
if not contains_acceptance_block(body):
|
|
return {
|
|
"valid": False,
|
|
"fields": {},
|
|
"reasons": ["missing Controller Issue Acceptance heading"],
|
|
}
|
|
|
|
fields = extract_acceptance_fields(body)
|
|
reasons: list[str] = []
|
|
|
|
for field in REQUIRED_FIELDS:
|
|
if _empty_or_placeholder(fields.get(field)):
|
|
reasons.append(f"missing required controller acceptance field: {field}")
|
|
|
|
state = _normalize_state(fields.get("STATE"))
|
|
if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES:
|
|
reasons.append(
|
|
"STATE must be accepted or a rejection path "
|
|
"(more-work-required, needs-tests, needs-docs, "
|
|
"needs-feature-enhancement, needs-follow-up-issue, blocked)"
|
|
)
|
|
|
|
actor = (fields.get("WHO_IS_NEXT") or "").strip().lower()
|
|
if actor and actor not in ALLOWED_NEXT_ACTORS:
|
|
reasons.append(
|
|
"WHO_IS_NEXT must be one of: "
|
|
+ ", ".join(sorted(ALLOWED_NEXT_ACTORS))
|
|
)
|
|
|
|
if not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""):
|
|
reasons.append("ISSUE must cite an issue number (#N)")
|
|
if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""):
|
|
reasons.append("MERGED_PR must cite a merged PR number (#N)")
|
|
if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""):
|
|
reasons.append("MERGE_COMMIT must include a full 40-character SHA")
|
|
|
|
criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or ""
|
|
if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria):
|
|
reasons.append(
|
|
"ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items"
|
|
)
|
|
|
|
decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower()
|
|
if state in ACCEPTED_STATES:
|
|
if decision not in {"accepted", "accept"}:
|
|
reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted")
|
|
if not _CHECKED_ITEM_RE.search(criteria):
|
|
reasons.append(
|
|
"accepted STATE requires at least one checked acceptance criterion"
|
|
)
|
|
if _empty_or_placeholder(fields.get("WHY")):
|
|
reasons.append("accepted STATE requires WHY with acceptance rationale")
|
|
elif state in REJECTION_STATES:
|
|
if decision not in {"rejected", "reject", "more_work_required"}:
|
|
reasons.append(
|
|
"rejection STATE requires CONTROLLER_DECISION: rejected"
|
|
)
|
|
if _empty_or_placeholder(fields.get("NEXT_PROMPT")):
|
|
reasons.append(
|
|
"rejection STATE requires a paste-ready NEXT_PROMPT for the next actor"
|
|
)
|
|
missing = fields.get("MISSING_WORK") or ""
|
|
if _empty_or_placeholder(missing):
|
|
reasons.append(
|
|
"rejection STATE requires MISSING_WORK describing what is still needed"
|
|
)
|
|
|
|
return {
|
|
"valid": not reasons,
|
|
"fields": fields,
|
|
"reasons": reasons,
|
|
}
|
|
|
|
|
|
def claims_issue_complete(text: str | None) -> bool:
|
|
"""Return True when text claims an issue is complete/accepted."""
|
|
return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or ""))
|
|
|
|
|
|
def claims_merge_only_issue_complete(text: str | None) -> bool:
|
|
"""Return True when text treats PR merge as issue completion."""
|
|
return bool(_MERGE_ONLY_COMPLETE_RE.search(text or ""))
|
|
|
|
|
|
def claims_controller_acceptance_update(text: str | None) -> bool:
|
|
return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or ""))
|
|
|
|
|
|
def notes_controller_acceptance_pending(text: str | None) -> bool:
|
|
return bool(_PENDING_ACCEPTANCE_RE.search(text or ""))
|
|
|
|
|
|
def validate_final_report_issue_acceptance(report_text: str | None) -> dict:
|
|
"""Validate issue-completion and controller-acceptance claims in final reports."""
|
|
text = report_text or ""
|
|
reasons: list[str] = []
|
|
|
|
complete_claim = claims_issue_complete(text)
|
|
merge_only = claims_merge_only_issue_complete(text)
|
|
acceptance_claim = claims_controller_acceptance_update(text)
|
|
pending_noted = notes_controller_acceptance_pending(text)
|
|
has_block = contains_acceptance_block(text)
|
|
|
|
if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]):
|
|
reasons.append(
|
|
"final report treats PR merge as issue completion without controller acceptance proof"
|
|
)
|
|
|
|
if complete_claim and not pending_noted:
|
|
if not has_block:
|
|
reasons.append(
|
|
"final report claims issue complete but includes no Controller Issue Acceptance block"
|
|
)
|
|
else:
|
|
result = validate_controller_acceptance_comment(text)
|
|
if not result["valid"]:
|
|
reasons.extend(result["reasons"])
|
|
else:
|
|
state = _normalize_state(result["fields"].get("STATE"))
|
|
if state not in ACCEPTED_STATES:
|
|
reasons.append(
|
|
"final report claims issue complete but controller STATE is not accepted"
|
|
)
|
|
|
|
if acceptance_claim and has_block:
|
|
result = validate_controller_acceptance_comment(text)
|
|
if not result["valid"]:
|
|
reasons.extend(result["reasons"])
|
|
|
|
applicable = complete_claim or merge_only or acceptance_claim or pending_noted
|
|
return {
|
|
"applicable": applicable,
|
|
"valid": not reasons,
|
|
"reasons": reasons,
|
|
} |