diff --git a/review_proofs.py b/review_proofs.py index 3356a23..c539a0b 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -1297,6 +1297,16 @@ HANDOFF_ROLE_FIELDS = { ("Session authored PR", ("session authored pr", "authored pr")), ("Why continuation allowed", ("why continuation", "continuation allowed")), ), + "issue_filing": ( + ("Issue created or updated", ( + "issue created or updated", + "issue created", + "issue updated", + "created issue", + "updated issue", + )), + ("Related issues", ("related issues",)), + ), } @@ -2135,6 +2145,309 @@ def assess_issue_selection_final_report( } +_SHA_EVIDENCE_LABEL = re.compile( + r"(?:\b(?:old|new)\s+head\b|\bhead\s+sha\b|\bcommit\s+sha\b|\bsha\b)" + r"\s*[:=]?\s*([0-9a-f]+)", + re.IGNORECASE, +) + + +def assess_issue_filing_sha_evidence(report_text: str) -> dict: + """Issue #191: commit evidence must use full 40-hex SHAs.""" + text = report_text or "" + reasons = [] + for match in _SHA_EVIDENCE_LABEL.finditer(text): + sha = match.group(1).strip().lower() + if sha and not _FULL_SHA.match(sha): + reasons.append( + f"abbreviated SHA in evidence ({len(sha)} hex chars); " + "full 40-character SHA required" + ) + return { + "complete": not reasons, + "downgraded": bool(reasons), + "reasons": reasons, + } + + +def assess_issue_filing_issue_reference( + report_text: str, + *, + issue_number: int | None = None, + issue_title: str | None = None, + action: str = "created", +) -> dict: + """Issue #191: reports must cite exact issue number and title.""" + text = (report_text or "").lower() + reasons = [] + if issue_number is None: + reasons.append("issue number proof missing from assessment context") + else: + num_patterns = (f"#{issue_number}", f"issue #{issue_number}", + f"issue {issue_number}") + if not any(p in text for p in num_patterns): + reasons.append( + f"report missing exact issue number #{issue_number}" + ) + if issue_title: + title_fragment = issue_title.strip().lower()[:40] + if title_fragment and title_fragment not in text: + reasons.append("report missing exact issue title") + action = (action or "created").strip().lower() + if action == "created" and "creat" not in text: + reasons.append( + "report must distinguish new issue creation from update" + ) + if action == "updated" and "updat" not in text: + reasons.append( + "report must distinguish issue update from new creation" + ) + return { + "complete": not reasons, + "downgraded": bool(reasons), + "reasons": reasons, + } + + +def assess_issue_filing_mutation_capability( + report_text: str, + mutations: list[str] | None, + resolved_capabilities: dict | None = None, +) -> dict: + """Issue #191: every mutation needs exact capability proof in the report.""" + import task_capability_map + + text = (report_text or "").lower() + reasons = [] + mutation_list = list(mutations or []) + if not mutation_list: + reasons.append("no mutations listed for capability proof") + cap_proof = assess_capability_proof(resolved_capabilities or {}) + if not cap_proof.get("proven"): + reasons.extend(cap_proof.get("reasons") or []) + + for task in mutation_list: + try: + permission = task_capability_map.required_permission(task) + except KeyError: + reasons.append(f"unknown mutation task '{task}'") + continue + if permission.lower() not in text and task.replace("_", " ") not in text: + reasons.append( + f"mutation '{task}' missing exact capability proof " + f"('{permission}')" + ) + if task == "set_issue_labels": + label_proof = ( + "label change", + "labels changed", + "set label", + "label mutation", + "issue labels", + "label:", + ) + if not any(p in text for p in label_proof): + reasons.append( + "label mutation missing label-specific capability proof" + ) + return { + "complete": not reasons, + "downgraded": bool(reasons), + "reasons": reasons, + } + + +def assess_issue_filing_mutation_scope( + report_text: str, + *, + performed_mutations: list[str] | None = None, + forbidden_mutations: list[str] | None = None, +) -> dict: + """Issue #191: single-mutation runs must state scope and absent mutations.""" + text = (report_text or "").lower() + performed = list(performed_mutations or []) + forbidden = list(forbidden_mutations or [ + "label", "comment", "pr", "review", "merge", "close", + ]) + reasons = [] + + if len(performed) == 1: + only_phrases = ("only mutation", "only mutations", "sole mutation") + if not any(p in text for p in only_phrases): + reasons.append( + "single-mutation run must state 'Only mutation(s): ...'" + ) + for absent in forbidden: + if absent == "comment" and performed[0] in ( + "comment_issue", "create_issue", + ): + continue + if absent not in text: + continue + denial_phrases = ( + f"no {absent}", + f"no {absent}s", + f"without {absent}", + "none performed", + "none.", + "confirm no", + ) + if any(phrase in text for phrase in denial_phrases): + continue + reasons.append( + f"single-mutation run mentions '{absent}' without " + f"confirming it was not performed" + ) + if performed == ["create_issue"]: + for check in ("label", "comment", "pr", "review", "merge"): + if check in text and f"no {check}" not in text: + if not any( + n in text + for n in ( + f"no {check}", + f"no {check}s", + "none performed", + "confirm no", + ) + ): + reasons.append( + f"create-only run must confirm no {check} " + "mutations were performed" + ) + + return { + "complete": not reasons, + "downgraded": bool(reasons), + "reasons": reasons, + } + + +def assess_issue_filing_duplicate_summary( + report_text: str, + *, + issues_searched: int | None = None, + closest_matches: list[dict] | None = None, + duplicate_result: dict | None = None, +) -> dict: + """Issue #191: duplicate-check evidence must be summarized in the report.""" + text = (report_text or "").lower() + reasons = [] + dup = duplicate_result or {} + + if issues_searched is not None: + count_tokens = ( + str(issues_searched), + f"{issues_searched} open", + f"{issues_searched} issue", + ) + if not any(t in text for t in count_tokens): + reasons.append( + "duplicate-check summary missing open-issues searched count" + ) + + matches = closest_matches if closest_matches is not None else dup.get("matches") + for match in matches or []: + num = match.get("number") + if num is not None and f"#{num}" not in text and str(num) not in text: + reasons.append( + f"duplicate-check summary missing closest issue #{num}" + ) + break + + justification_phrases = ( + "why new", + "why update", + "rejected update", + "new issue justified", + "not a duplicate", + "no duplicate", + "justified", + "instead of expanding", + ) + if not any(p in text for p in justification_phrases): + reasons.append( + "duplicate-check summary missing why update was rejected or " + "why a new issue was justified" + ) + + dup_proof = issue_duplicate_gate.assess_duplicate_search_proof( + report_text, matches or [] + ) + if not dup_proof.get("valid"): + reasons.extend(dup_proof.get("reasons") or []) + + return { + "complete": not reasons, + "downgraded": bool(reasons), + "reasons": reasons, + } + + +def assess_issue_filing_final_report( + report_text: str, + *, + issue_number: int | None = None, + issue_title: str | None = None, + action: str = "created", + mutations: list[str] | None = None, + resolved_capabilities: dict | None = None, + issues_searched: int | None = None, + closest_matches: list[dict] | None = None, + duplicate_result: dict | None = None, + performed_mutations: list[str] | None = None, +) -> dict: + """Issue #191: composite A-bar for issue-filing final reports.""" + checks = { + "controller_handoff": assess_controller_handoff( + report_text, role="issue_filing" + ), + "issue_reference": assess_issue_filing_issue_reference( + report_text, + issue_number=issue_number, + issue_title=issue_title, + action=action, + ), + "sha_evidence": assess_issue_filing_sha_evidence(report_text), + "mutation_capability": assess_issue_filing_mutation_capability( + report_text, + mutations or performed_mutations, + resolved_capabilities, + ), + "mutation_scope": assess_issue_filing_mutation_scope( + report_text, + performed_mutations=performed_mutations or mutations, + ), + "duplicate_summary": assess_issue_filing_duplicate_summary( + report_text, + issues_searched=issues_searched, + closest_matches=closest_matches, + duplicate_result=duplicate_result, + ), + } + + reasons = [] + downgraded = False + for name, result in checks.items(): + verdict = result.get("verdict") + if verdict in ("missing", "incomplete"): + downgraded = True + reasons.extend(result.get("reasons") or []) + elif result.get("downgraded") or not result.get("complete", True): + downgraded = True + reasons.extend( + f"{name}: {r}" for r in (result.get("reasons") or []) + ) + + grade = "A" if not downgraded else "downgraded" + return { + "grade": grade, + "downgraded": downgraded, + "checks": checks, + "reasons": reasons, + "complete": not downgraded, + } + + def assess_duplicate_search_proof(report_text, matches): """#207: reject LLM duplicate summaries that omit known title matches.""" return issue_duplicate_gate.assess_duplicate_search_proof( diff --git a/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index fcefe8f..cfb723a 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -462,6 +462,12 @@ Role-specific fields (append to the compact block): - review/merge tasks: `Selected PR:`, `Reviewer eligibility:`, `Pinned reviewed head:`, `Review decision:`, `Merge result:`, `Linked issue status:`, `Cleanup status:` +- issue-filing tasks (#191): `Issue created or updated:`, `Related issues:`; + body must cite exact issue number/title, duplicate-search summary (issues + searched, closest matches, why update rejected / new issue justified), full + 40-char SHAs when citing commits, exact mutation capability per change, and + `Only mutation(s):` when a single mutation was performed + (`review_proofs.assess_issue_filing_final_report`). - author tasks: `Selected issue:`, `Claim/comment status:`, `PR number opened:`, `No review/merge:` (explicit confirmation) - continuation tasks (#188): `Continuation mode:`, `Existing PR:`, diff --git a/tests/test_review_proofs.py b/tests/test_review_proofs.py index 92bdfc0..7166cca 100644 --- a/tests/test_review_proofs.py +++ b/tests/test_review_proofs.py @@ -33,6 +33,9 @@ from review_proofs import ( # noqa: E402 assess_empty_queue_report, assess_fresh_issue_selection, assess_inventory_completeness, + assess_issue_filing_final_report, + assess_issue_filing_mutation_capability, + assess_issue_filing_sha_evidence, assess_issue_selection_final_report, assess_queue_target_final_report, assess_reviewer_queue_inventory, @@ -1929,5 +1932,115 @@ class TestIssueSelectionContinuation(unittest.TestCase): self.assertEqual(result["grade"], "A") +class TestIssueFilingFinalReport(unittest.TestCase): + """Issue #191: issue-filing runs need A-bar final report proofs.""" + + ISSUE_TITLE = ( + "Implement fail-closed continuation mode for issues already " + "represented by open PRs" + ) + FULL_SHA = "a4060c5de00f2b1c9e88f4f6f0f3f9a7b2c1d0e9" + CAPABILITIES = { + "create_issue": { + "requested_task": "create_issue", + "required_operation_permission": "gitea.issue.create", + "allowed_in_current_session": True, + }, + } + CLOSEST = [{"number": 183, "title": "Harden author-run reporting", "state": "open"}] + + def _good_report(self, *, sha_line=None): + lines = [ + "Created Gitea-Tools #189 — " + f"`{self.ISSUE_TITLE}`", + "Duplicate check: searched 12 open issues.", + "Closest existing: #183 — Harden author-run reporting " + "(update rejected — different scope).", + "Why new issue justified: continuation mode is distinct from #183.", + "Only mutation: issue creation (gitea.issue.create).", + "Confirm no labels, comments, PRs, reviews, merges, or closes.", + "## Controller Handoff", + "- Task: file issue", + "- Repo: Scaled-Tech-Consulting/Gitea-Tools", + "- Role: author", + "- Identity: prgs-author", + "- Issue/PR: #189", + "- Branch/SHA: n/a", + "- Files changed: none", + "- Validation: duplicate gate + create_issue capability resolved", + "- Mutations: create_issue via gitea.issue.create", + "- Workspace mutations: none", + "- Current status: issue created", + "- Blockers: none", + "- Next: implementation", + "- Safety: no review/merge", + "- Issue created or updated: created #189", + "- Related issues: #183, #188", + ] + if sha_line: + lines.insert(4, sha_line) + return "\n".join(lines) + + def test_complete_issue_filing_report_earns_a(self): + result = assess_issue_filing_final_report( + self._good_report(), + issue_number=189, + issue_title=self.ISSUE_TITLE, + action="created", + mutations=["create_issue"], + resolved_capabilities=self.CAPABILITIES, + issues_searched=12, + closest_matches=self.CLOSEST, + performed_mutations=["create_issue"], + ) + self.assertEqual(result["grade"], "A") + self.assertFalse(result["downgraded"]) + + def test_missing_controller_handoff_downgrades(self): + report = self._good_report().replace("## Controller Handoff", "") + result = assess_issue_filing_final_report( + report, + issue_number=189, + issue_title=self.ISSUE_TITLE, + mutations=["create_issue"], + resolved_capabilities=self.CAPABILITIES, + issues_searched=12, + performed_mutations=["create_issue"], + ) + self.assertTrue(result["downgraded"]) + + def test_abbreviated_sha_downgrades(self): + result = assess_issue_filing_sha_evidence( + f"old head: {self.FULL_SHA[:7]}" + ) + self.assertTrue(result["downgraded"]) + + def test_mutation_without_capability_proof_downgrades(self): + report = self._good_report().replace("gitea.issue.create", "allowed") + result = assess_issue_filing_mutation_capability( + report, + ["create_issue"], + self.CAPABILITIES, + ) + self.assertTrue(result["downgraded"]) + + def test_label_mutation_without_label_proof_downgrades(self): + caps = { + "set_issue_labels": { + "requested_task": "set_issue_labels", + "required_operation_permission": "gitea.issue.comment", + "allowed_in_current_session": True, + }, + } + report = "\n".join([ + "Updated labels with gitea.issue.comment capability resolved.", + "Only mutations: set_issue_labels", + ]) + result = assess_issue_filing_mutation_capability( + report, ["set_issue_labels"], caps + ) + self.assertTrue(result["downgraded"]) + + if __name__ == "__main__": unittest.main()