From 9235f3a23caf9531191380bbaf09b88740a92894 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 03:17:38 -0400 Subject: [PATCH 1/8] feat: require canonical next-action state comments (#495) Add canonical issue/PR/discussion state comment templates and validators, final-report rules for STATE/WHO_IS_NEXT/NEXT_ACTION/NEXT_PROMPT, and queue-controller guidance. Posting enforcement remains in #496. Co-Authored-By: Claude Opus 4.8 (1M context) --- canonical_state_comments.py | 171 ++++++++++++++++ docs/canonical-state-comments.md | 183 ++++++++++++++++++ docs/llm-workflow-runbooks.md | 5 + final_report_validator.py | 19 ++ skills/llm-project-workflow/SKILL.md | 3 +- .../templates/canonical-state-comments.md | 148 ++++++++++++++ tests/test_canonical_state_comments.py | 143 ++++++++++++++ tests/test_final_report_validator.py | 86 ++++++++ 8 files changed, 757 insertions(+), 1 deletion(-) create mode 100644 canonical_state_comments.py create mode 100644 docs/canonical-state-comments.md create mode 100644 skills/llm-project-workflow/templates/canonical-state-comments.md create mode 100644 tests/test_canonical_state_comments.py diff --git a/canonical_state_comments.py b/canonical_state_comments.py new file mode 100644 index 0000000..cc819ac --- /dev/null +++ b/canonical_state_comments.py @@ -0,0 +1,171 @@ +"""Canonical state comment validation helpers (#495). + +These helpers validate durable issue/PR/discussion state handoff comments. +They are intentionally pure and do not post comments; MCP mutation hooks are +owned by #496. +""" + +from __future__ import annotations + +import re + +CANONICAL_HEADINGS = ( + "canonical issue state", + "canonical pr state", + "canonical discussion summary", +) + +REQUIRED_FIELDS = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT") +ALLOWED_NEXT_ACTORS = { + "controller", + "author", + "reviewer", + "merger", + "reconciler", + "user", +} + +VAGUE_NEXT_ACTIONS = { + "continue", + "handle this", + "do it", + "fix it", + "proceed", + "follow up", + "next", + "tbd", + "todo", + "n/a", + "none", +} + +_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) +_CLAIMS_STATE_UPDATE_RE = re.compile( + r"canonical\s+(?:issue|pr|discussion)?\s*state|" + r"state\s+comment\s+(?:posted|created|updated)|" + r"next[- ]action\s+comment\s+(?:posted|created|updated)", + re.IGNORECASE, +) + + +def extract_state_fields(text: str | None) -> dict[str, str]: + """Return upper-case labeled fields from a canonical state 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 and not stripped.startswith("#"): + existing = fields.get(current_key, "") + fields[current_key] = ( + f"{existing}\n{stripped}" if existing else stripped + ) + return fields + + +def contains_canonical_state_block(text: str | None) -> bool: + lower = (text or "").lower() + return any(heading in lower for heading in CANONICAL_HEADINGS) + + +def claims_canonical_state_update(text: str | None) -> bool: + """Return True when text claims a canonical state/next-action update.""" + return bool(_CLAIMS_STATE_UPDATE_RE.search(text or "")) + + +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 _vague_next_action(value: str | None) -> bool: + normalized = re.sub(r"\s+", " ", (value or "").strip().lower()) + return normalized in VAGUE_NEXT_ACTIONS + + +def validate_canonical_state_comment(text: str | None) -> dict: + """Validate a canonical state comment or embedded final-report block. + + Returns a dict with ``valid`` and ``reasons``. The validator focuses on + fields that make continuation possible: current state, next actor, next + action, and paste-ready next prompt, plus a few contradiction checks. + """ + fields = extract_state_fields(text) + reasons: list[str] = [] + + for field in REQUIRED_FIELDS: + if _empty_or_placeholder(fields.get(field)): + reasons.append(f"missing required canonical state field: {field}") + + 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 _vague_next_action(fields.get("NEXT_ACTION")): + reasons.append("NEXT_ACTION is too vague for durable continuation") + + state = (fields.get("STATE") or "").strip().lower().replace("-", "_") + if "ready_to_merge" in state or ( + state == "approved" and "pr" in (text or "").lower() + ): + review_status = (fields.get("REVIEW_STATUS") or "").lower() + head_sha = fields.get("HEAD_SHA") or "" + merge_ready = (fields.get("MERGE_READY") or "").lower() + if "approved" not in review_status: + reasons.append("ready-to-merge state requires approved REVIEW_STATUS") + if not _FULL_SHA_RE.search(head_sha): + reasons.append("ready-to-merge state requires full HEAD_SHA proof") + if merge_ready and not merge_ready.startswith(("yes", "true")): + reasons.append("ready-to-merge state contradicts MERGE_READY") + + if "superseded" in state: + canonical = fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY") + if _empty_or_placeholder(canonical): + reasons.append("superseded state requires canonical item proof") + + if "blocked" in state: + blockers = fields.get("BLOCKERS") or "" + if _empty_or_placeholder(blockers): + reasons.append("blocked state requires BLOCKERS/unblock condition") + + return { + "valid": not reasons, + "fields": fields, + "reasons": reasons, + } + + +def validate_final_report_state_update(report_text: str | None) -> dict: + """Validate canonical state-update claims inside a final report.""" + text = report_text or "" + if not claims_canonical_state_update(text) and not contains_canonical_state_block(text): + return { + "applicable": False, + "valid": True, + "reasons": [], + } + + if not contains_canonical_state_block(text): + return { + "applicable": True, + "valid": False, + "reasons": [ + "final report claims a canonical state update but includes no canonical state block" + ], + } + + result = validate_canonical_state_comment(text) + return { + "applicable": True, + "valid": result["valid"], + "fields": result["fields"], + "reasons": result["reasons"], + } diff --git a/docs/canonical-state-comments.md b/docs/canonical-state-comments.md new file mode 100644 index 0000000..555a91b --- /dev/null +++ b/docs/canonical-state-comments.md @@ -0,0 +1,183 @@ +# Canonical State Comments + +Gitea is the durable system of record for workflow continuation. When a +comment changes issue, PR, or discussion state, it should leave enough +information for the next role to continue without private chat history. + +Canonical comments answer: + +- what state the object is in +- who acts next +- what the next actor should do +- the exact prompt the next actor should run +- which proof, blocker, or dependency matters + +Non-workflow discussion comments do not need this template. + +## Issue State + +Use this when an issue becomes ready, blocked, in progress, PR-open, +superseded, merged, or otherwise changes workflow direction. + +```text +## Canonical Issue State + +STATE: + + +WHO_IS_NEXT: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +WHAT_HAPPENED: + + +WHY: + + +RELATED_DISCUSSION: + + +RELATED_PRS: +- #... + +BRANCH: + + +HEAD_SHA: +<40-character SHA or none> + +VALIDATION: + + +BLOCKERS: + + +LAST_UPDATED_BY: + +``` + +## PR State + +Use this when a PR needs review, receives changes requested, is approved, +is stale, is superseded, or becomes ready for merge. + +```text +## Canonical PR State + +STATE: + + +WHO_IS_NEXT: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +WHAT_HAPPENED: + + +WHY: + + +ISSUE: +#... + +BASE: + + +HEAD: + + +HEAD_SHA: +<40-character SHA> + +REVIEW_STATUS: + + +VALIDATION: + + +BLOCKERS: + + +SUPERSEDES: + + +SUPERSEDED_BY: + + +MERGE_READY: + + +LAST_UPDATED_BY: + +``` + +## Discussion Summary + +Discussions should normally have at least five substantive comments before +conversion into issues. A controller may waive that only for tiny mechanical, +urgent, or explicitly trivial work. + +```text +## Canonical Discussion Summary + +STATE: + + +WHO_IS_NEXT: + + +DECISION: + + +WHY: + + +SUBSTANTIVE_COMMENTS: + + +ISSUES_TO_CREATE_OR_CREATED: +- #... + +DEPENDENCY_ORDER: + + +NON_GOALS: + + +OPEN_QUESTIONS: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +LAST_UPDATED_BY: + +``` + +## Validation Rules + +The final-report validator rejects canonical state update claims when the +report omits the canonical block or when the block lacks: + +- `STATE` +- `WHO_IS_NEXT` +- `NEXT_ACTION` +- `NEXT_PROMPT` + +It also rejects vague next actions such as `continue`, ready-to-merge states +without approval/head-SHA proof, superseded states without canonical item +proof, and blocked states without an unblock condition. diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index e0ff616..1c4f05d 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -7,6 +7,11 @@ package of the MCP Control Plane: creating issues, implementing them, opening and reviewing pull requests, merging, and closing out — safely and reproducibly. +Canonical state comments for durable issue/PR/discussion continuation are +documented in [`canonical-state-comments.md`](canonical-state-comments.md). +Use them when a workflow-changing comment needs to leave the next actor, next +action, and paste-ready prompt in Gitea. + > For the **project-agnostic** version of these operating rules (issue-first, > isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed) > that can be copied into any repository, see the reusable skill diff --git a/final_report_validator.py b/final_report_validator.py index 352a3ff..bf9baa4 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -1042,10 +1042,29 @@ def _rule_reviewer_review_mutation( ) +def _rule_shared_canonical_state_update(report_text: str) -> list[dict[str, str]]: + from canonical_state_comments import validate_final_report_state_update + + result = validate_final_report_state_update(report_text) + if not result.get("applicable") or result.get("valid"): + return [] + return [ + validator_finding( + "shared.canonical_state_update", + "block", + "Canonical state update", + reason, + "include a canonical state block with STATE, WHO_IS_NEXT, NEXT_ACTION, and NEXT_PROMPT", + ) + for reason in (result.get("reasons") or ["invalid canonical state update"]) + ] + + _SHARED_ISSUE_LOCK_RULES = ( _rule_shared_issue_lock_external_state, _rule_shared_manual_lock_pr_override, _rule_shared_author_reviewer_same_run, + _rule_shared_canonical_state_update, ) _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { diff --git a/skills/llm-project-workflow/SKILL.md b/skills/llm-project-workflow/SKILL.md index dc2f961..956289b 100644 --- a/skills/llm-project-workflow/SKILL.md +++ b/skills/llm-project-workflow/SKILL.md @@ -167,6 +167,7 @@ Ready-to-copy task prompts live in [`templates/`](templates/): - [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) - [`worktree-cleanup.md`](templates/worktree-cleanup.md) - [`release-tag.md`](templates/release-tag.md) +- [`canonical-state-comments.md`](templates/canonical-state-comments.md) ## Adapting to a project @@ -182,4 +183,4 @@ Ready-to-copy task prompts live in [`templates/`](templates/): Releases follow SemVer from remote `master` only, after full test suite passes. See [`templates/release-tag.md`](templates/release-tag.md) and -`scripts/release-tag`. \ No newline at end of file +`scripts/release-tag`. diff --git a/skills/llm-project-workflow/templates/canonical-state-comments.md b/skills/llm-project-workflow/templates/canonical-state-comments.md new file mode 100644 index 0000000..d6ec2fd --- /dev/null +++ b/skills/llm-project-workflow/templates/canonical-state-comments.md @@ -0,0 +1,148 @@ +# Template: canonical state comments + +Use these only for workflow-changing comments. Casual discussion does not need +the full template. + +## Issue + +```text +## Canonical Issue State + +STATE: + + +WHO_IS_NEXT: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +WHAT_HAPPENED: + + +WHY: + + +RELATED_DISCUSSION: + + +RELATED_PRS: +- #... + +BRANCH: + + +HEAD_SHA: +<40-character SHA or none> + +VALIDATION: + + +BLOCKERS: + + +LAST_UPDATED_BY: + +``` + +## PR + +```text +## Canonical PR State + +STATE: + + +WHO_IS_NEXT: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +WHAT_HAPPENED: + + +WHY: + + +ISSUE: +#... + +BASE: + + +HEAD: + + +HEAD_SHA: +<40-character SHA> + +REVIEW_STATUS: + + +VALIDATION: + + +BLOCKERS: + + +SUPERSEDES: + + +SUPERSEDED_BY: + + +MERGE_READY: + + +LAST_UPDATED_BY: + +``` + +## Discussion + +```text +## Canonical Discussion Summary + +STATE: + + +WHO_IS_NEXT: + + +DECISION: + + +WHY: + + +SUBSTANTIVE_COMMENTS: + + +ISSUES_TO_CREATE_OR_CREATED: +- #... + +DEPENDENCY_ORDER: + + +NON_GOALS: + + +OPEN_QUESTIONS: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +LAST_UPDATED_BY: + +``` diff --git a/tests/test_canonical_state_comments.py b/tests/test_canonical_state_comments.py new file mode 100644 index 0000000..8f8c5ae --- /dev/null +++ b/tests/test_canonical_state_comments.py @@ -0,0 +1,143 @@ +"""Tests for canonical state comment validation (#495).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from canonical_state_comments import ( # noqa: E402 + validate_canonical_state_comment, + validate_final_report_state_update, +) + + +GOOD_ISSUE_STATE = """## Canonical Issue State + +STATE: +ready-for-author + +WHO_IS_NEXT: +author + +NEXT_ACTION: +Implement the focused validator and open a PR. + +NEXT_PROMPT: +Find issue #495, load the work-issue workflow, implement the validator, and open a PR. + +WHAT_HAPPENED: +Controller selected the canonical tracking issue. + +WHY: +The duplicate issue points here as canonical. + +RELATED_PRS: +none + +BRANCH: +none + +HEAD_SHA: +none + +VALIDATION: +none + +BLOCKERS: +none +""" + + +class TestCanonicalStateComments(unittest.TestCase): + def test_good_issue_state_validates(self): + result = validate_canonical_state_comment(GOOD_ISSUE_STATE) + self.assertTrue(result["valid"]) + self.assertEqual(result["reasons"], []) + + def test_missing_next_actor_is_rejected(self): + result = validate_canonical_state_comment( + GOOD_ISSUE_STATE.replace("WHO_IS_NEXT:\nauthor", "WHO_IS_NEXT:\n") + ) + self.assertFalse(result["valid"]) + self.assertIn("WHO_IS_NEXT", " ".join(result["reasons"])) + + def test_missing_next_prompt_is_rejected(self): + result = validate_canonical_state_comment( + GOOD_ISSUE_STATE.replace( + "NEXT_PROMPT:\nFind issue #495, load the work-issue workflow, implement the validator, and open a PR.", + "NEXT_PROMPT:\n", + ) + ) + self.assertFalse(result["valid"]) + self.assertIn("NEXT_PROMPT", " ".join(result["reasons"])) + + def test_vague_next_action_is_rejected(self): + result = validate_canonical_state_comment( + GOOD_ISSUE_STATE.replace( + "NEXT_ACTION:\nImplement the focused validator and open a PR.", + "NEXT_ACTION:\ncontinue", + ) + ) + self.assertFalse(result["valid"]) + self.assertIn("too vague", " ".join(result["reasons"])) + + def test_ready_to_merge_requires_approval_and_head_sha(self): + text = """## Canonical PR State + +STATE: +ready-to-merge + +WHO_IS_NEXT: +merger + +NEXT_ACTION: +Merge PR #12 after checking the current head. + +NEXT_PROMPT: +Load the merge workflow and merge PR #12 if gates pass. + +ISSUE: +#11 + +HEAD_SHA: +abc123 + +REVIEW_STATUS: +none + +MERGE_READY: +yes +""" + result = validate_canonical_state_comment(text) + self.assertFalse(result["valid"]) + reasons = " ".join(result["reasons"]) + self.assertIn("approved REVIEW_STATUS", reasons) + self.assertIn("full HEAD_SHA", reasons) + + def test_superseded_requires_canonical_item(self): + text = GOOD_ISSUE_STATE.replace("STATE:\nready-for-author", "STATE:\nsuperseded") + result = validate_canonical_state_comment(text) + self.assertFalse(result["valid"]) + self.assertIn("canonical item", " ".join(result["reasons"])) + + def test_blocked_requires_unblock_condition(self): + text = GOOD_ISSUE_STATE.replace("STATE:\nready-for-author", "STATE:\nblocked") + text = text.replace("BLOCKERS:\nnone", "BLOCKERS:\n") + result = validate_canonical_state_comment(text) + self.assertFalse(result["valid"]) + self.assertIn("BLOCKERS", " ".join(result["reasons"])) + + def test_final_report_claim_without_block_is_rejected(self): + report = "Posted canonical state comment on issue #495." + result = validate_final_report_state_update(report) + self.assertTrue(result["applicable"]) + self.assertFalse(result["valid"]) + + def test_final_report_without_state_claim_not_applicable(self): + result = validate_final_report_state_update("ordinary final report") + self.assertFalse(result["applicable"]) + self.assertTrue(result["valid"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_final_report_validator.py b/tests/test_final_report_validator.py index 88c5b23..6c510e4 100644 --- a/tests/test_final_report_validator.py +++ b/tests/test_final_report_validator.py @@ -345,6 +345,92 @@ class TestReconciliationRules(unittest.TestCase): ) +class TestCanonicalStateUpdateRules(unittest.TestCase): + def test_claimed_state_comment_without_block_blocks(self): + report = _reconcile_handoff() + "\nPosted canonical state comment on PR #99." + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertTrue(result["blocked"]) + self.assertTrue( + any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"]) + ) + + def test_state_comment_missing_next_prompt_blocks(self): + report = ( + _reconcile_handoff() + + """ + +## Canonical PR State + +STATE: +needs-review + +WHO_IS_NEXT: +reviewer + +NEXT_ACTION: +Review PR #99. + +NEXT_PROMPT: + +ISSUE: +#98 + +HEAD_SHA: +0fdc8f582026b72a229d59a172c0a63ac4aaeaf9 + +REVIEW_STATUS: +none + +MERGE_READY: +no +""" + ) + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertTrue(result["blocked"]) + reasons = " ".join(f["reason"] for f in result["findings"]) + self.assertIn("NEXT_PROMPT", reasons) + + def test_valid_state_comment_claim_passes_shared_rule(self): + report = ( + _reconcile_handoff() + + """ + +## Canonical PR State + +STATE: +needs-review + +WHO_IS_NEXT: +reviewer + +NEXT_ACTION: +Review PR #99 against the pinned head. + +NEXT_PROMPT: +Load the review workflow and review PR #99. + +ISSUE: +#98 + +HEAD_SHA: +0fdc8f582026b72a229d59a172c0a63ac4aaeaf9 + +REVIEW_STATUS: +none + +MERGE_READY: +no + +BLOCKERS: +none +""" + ) + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertFalse( + any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"]) + ) + + class TestCanonicalReconcileSchema(unittest.TestCase): """Issue #307: reconciliation workflows emit only the canonical schema.""" From 429faccd089b4c664390d8c1e97d2ccc30e1d938 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 03:33:31 -0400 Subject: [PATCH 2/8] feat: reject contradictory reviewer handoff ledgers (Closes #501) Add reviewer_handoff_consistency validation for narrative vs mutation ledger contradictions, wire it into review_pr final-report rules, and document the blocked-review handoff template. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/llm-workflow-runbooks.md | 1 + docs/reviewer-handoff-consistency.md | 35 ++++ final_report_validator.py | 21 ++ reviewer_handoff_consistency.py | 186 ++++++++++++++++++ .../templates/blocked-review-handoff.md | 22 +++ tests/test_reviewer_handoff_consistency.py | 120 +++++++++++ 6 files changed, 385 insertions(+) create mode 100644 docs/reviewer-handoff-consistency.md create mode 100644 reviewer_handoff_consistency.py create mode 100644 skills/llm-project-workflow/templates/blocked-review-handoff.md create mode 100644 tests/test_reviewer_handoff_consistency.py diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index e0ff616..5748d4a 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -821,6 +821,7 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push ## Related documents +- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501). - [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill. - [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model. - [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) — static author/reviewer namespace deployment (#139 decision). diff --git a/docs/reviewer-handoff-consistency.md b/docs/reviewer-handoff-consistency.md new file mode 100644 index 0000000..27d8108 --- /dev/null +++ b/docs/reviewer-handoff-consistency.md @@ -0,0 +1,35 @@ +# Reviewer Handoff Consistency + +Reviewer and final-review controller handoffs must not contradict themselves. +A narrative that says a merge happened, a review was blocked, or a terminal +mutation budget was consumed must match the mutation ledger fields in the same +handoff. + +## What gets validated + +`reviewer_handoff_consistency.assess_reviewer_handoff_consistency()` checks: + +- merge claims appear under `Merge mutations` or `MCP/Gitea mutations` +- terminal-mutation-budget claims name the exact prior mutation in the ledger +- blocked review submission is not paired with "final decision marked" +- reviewer lease acquisition includes a `Review decision` +- blocked/rejected mutations include proof fields: + - tool called + - mutation attempted + - mutation rejected + - no server-side state changed + +`final_report_validator` applies this as `reviewer.handoff_consistency` on +`review_pr` reports and fails closed. + +## Blocked review template + +When `gitea_submit_pr_review` fails closed, use +`reviewer_handoff_consistency.render_blocked_review_handoff_template()` or the +copy in +[`skills/llm-project-workflow/templates/blocked-review-handoff.md`](../skills/llm-project-workflow/templates/blocked-review-handoff.md). + +## Related + +- #331 — file-mutation ledger alignment +- #501 — contradictory narrative vs ledger detection \ No newline at end of file diff --git a/final_report_validator.py b/final_report_validator.py index 352a3ff..16cc079 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -12,6 +12,7 @@ import re from typing import Any, Callable import issue_lock_provenance +import reviewer_handoff_consistency from review_proofs import ( HANDOFF_HEADING, assess_controller_handoff, @@ -956,6 +957,25 @@ def _rule_reviewer_validation_structured( ) +def _rule_reviewer_handoff_consistency(report_text: str) -> list[dict[str, str]]: + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency( + report_text + ) + if result.get("proven"): + return [] + return _findings_from_reasons( + "reviewer.handoff_consistency", + result.get("reasons") or [], + field="Review mutations", + severity="block", + safe_next_action=( + "rewrite reviewer handoff so narrative claims match the mutation " + "ledger; use the blocked-review handoff template when submission " + "was rejected" + ), + ) + + def _rule_reviewer_mutation_ledger( report_text: str, *, @@ -1069,6 +1089,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { _rule_reviewer_already_landed_eligible, _rule_reviewer_already_landed_state, _rule_reviewer_target_branch_freshness, + _rule_reviewer_handoff_consistency, _rule_reviewer_mutation_ledger, _rule_reviewer_review_mutation, _rule_reviewer_stale_head_proof, diff --git a/reviewer_handoff_consistency.py b/reviewer_handoff_consistency.py new file mode 100644 index 0000000..a6deeca --- /dev/null +++ b/reviewer_handoff_consistency.py @@ -0,0 +1,186 @@ +"""Reviewer handoff consistency validation (#501). + +Detects contradictions between narrative claims and mutation ledger fields in +reviewer/final-review controller handoffs. Pure validation only — does not post +comments or mutate Gitea state. +""" + +from __future__ import annotations + +import re + +_HANDOFF_FIELD_RE = re.compile( + r"^\s*[-*]\s*([^:]+):\s*(.*)$", + re.MULTILINE | re.IGNORECASE, +) + +_MERGE_NARRATIVE_RE = re.compile( + r"\b(?:merged\s+pr\s*#|pr\s+#\d+\s+(?:was\s+)?merged|" + r"merge\s+result\s*:\s*merged|successfully\s+merged|" + r"terminal\s+mutation\s+budget.{0,80}\bmerge)\b", + re.IGNORECASE | re.DOTALL, +) +_REVIEW_SUBMIT_BLOCKED_RE = re.compile( + r"\b(?:review\s+submission\s+blocked|submit_pr_review\s+(?:failed|blocked)|" + r"gitea_submit_pr_review\s+(?:failed|blocked|not\s+(?:attempted|performed))|" + r"review\s+not\s+submitted|could\s+not\s+submit\s+review)\b", + re.IGNORECASE, +) +_FINAL_DECISION_MARKED_RE = re.compile( + r"\b(?:gitea_mark_final_review_decision|final\s+review\s+decision\s+marked|" + r"server-side\s+final\s+decision\s+marked|marked\s+final\s+review\s+decision)\b", + re.IGNORECASE, +) +_TERMINAL_BUDGET_CONSUMED_RE = re.compile( + r"\b(?:terminal\s+mutation\s+budget\s+(?:already\s+)?consumed|" + r"single[- ]terminal\s+mutation\s+(?:already\s+)?consumed|" + r"mutation\s+budget\s+exhausted)\b", + re.IGNORECASE, +) +_LEASE_NARRATIVE_RE = re.compile( + r"\b(?:reviewer\s+lease\s+acquired|acquired\s+reviewer\s+pr\s+lease|" + r"gitea_acquire_reviewer_pr_lease)\b", + re.IGNORECASE, +) +_REJECTED_MUTATION_RE = re.compile( + r"\b(?:mutation\s+rejected|tool\s+failed\s+closed|blocked\s+before\s+mutation|" + r"no\s+server-side\s+state\s+changed)\b", + re.IGNORECASE, +) +_MERGE_IN_LEDGER_RE = re.compile(r"\bmerge\b", re.IGNORECASE) +_REVIEW_SUBMITTED_IN_LEDGER_RE = re.compile( + r"\b(?:submitted|approve|request_changes|review\s+submitted)\b", + re.IGNORECASE, +) +_NONE_VALUE_RE = re.compile(r"^\s*(?:none|not\s+attempted|n/a)\s*$", re.IGNORECASE) + +_BLOCKED_REVIEW_PROOF_FIELDS = ( + "tool called", + "mutation attempted", + "mutation rejected", + "no server-side state changed", +) + + +def render_blocked_review_handoff_template() -> str: + """Return a corrected handoff template for blocked review submissions.""" + return """## Blocked Review Submission Handoff + +- Tool called: gitea_submit_pr_review +- Mutation attempted: yes +- Mutation rejected: yes +- No server-side state changed: confirmed +- Proof/source: +- Prior terminal mutation that consumed budget: +- Review decision (local intent): +- Server-side final decision marked: +- Gitea review submitted: no +- Gitea review blocked because: +- Review mutations: none (submission blocked) +- MCP/Gitea mutations: +- Safe next action: rewrite handoff with consistent ledger before posting +""" + + +def _handoff_fields(text: str | None) -> dict[str, str]: + fields: dict[str, str] = {} + for match in _HANDOFF_FIELD_RE.finditer(text or ""): + key = match.group(1).strip().lower() + value = match.group(2).strip() + fields[key] = value + return fields + + +def _is_none_value(value: str | None) -> bool: + return bool(_NONE_VALUE_RE.match(value or "")) + + +def _ledger_mentions_merge(*values: str | None) -> bool: + blob = " ".join(v for v in values if v) + return bool(blob) and bool(_MERGE_IN_LEDGER_RE.search(blob)) + + +def _ledger_mentions_review_submission(*values: str | None) -> bool: + blob = " ".join(v for v in values if v) + return bool(blob) and bool(_REVIEW_SUBMITTED_IN_LEDGER_RE.search(blob)) + + +def assess_reviewer_handoff_consistency(report_text: str | None) -> dict: + """Validate reviewer handoff narrative against mutation ledger fields.""" + text = report_text or "" + fields = _handoff_fields(text) + reasons: list[str] = [] + + review_mutations = fields.get("review mutations", "") + merge_mutations = fields.get("merge mutations", "") + mcp_mutations = fields.get("mcp/gitea mutations", "") + review_decision = fields.get("review decision", "") + + if _MERGE_NARRATIVE_RE.search(text) and not _ledger_mentions_merge( + merge_mutations, mcp_mutations, review_mutations + ): + reasons.append( + "narrative claims a merge occurred but mutation ledger omits merge" + ) + + if _TERMINAL_BUDGET_CONSUMED_RE.search(text): + if _ledger_mentions_merge(merge_mutations, mcp_mutations): + pass + elif _LEASE_NARRATIVE_RE.search(text) and not _ledger_mentions_merge( + merge_mutations, mcp_mutations + ): + reasons.append( + "terminal mutation budget attributed to merge but ledger only " + "shows lease or non-merge activity" + ) + elif not _ledger_mentions_merge(merge_mutations, mcp_mutations): + reasons.append( + "terminal mutation budget consumed claim must identify the " + "exact prior mutation in the ledger" + ) + + if _REVIEW_SUBMIT_BLOCKED_RE.search(text) and _FINAL_DECISION_MARKED_RE.search(text): + reasons.append( + "handoff claims review submission blocked but also claims " + "server-side final decision was marked" + ) + + lease_claimed = _LEASE_NARRATIVE_RE.search(text) or ( + not _is_none_value(mcp_mutations) + and "lease" in mcp_mutations.lower() + ) + if lease_claimed and _is_none_value(review_decision): + reasons.append( + "reviewer lease acquired but Review decision field is missing or none" + ) + + if _REVIEW_SUBMIT_BLOCKED_RE.search(text) or _REJECTED_MUTATION_RE.search(text): + lower = text.lower() + missing_proof = [ + field + for field in _BLOCKED_REVIEW_PROOF_FIELDS + if field not in lower + ] + if missing_proof: + reasons.append( + "blocked/rejected mutation claim missing proof fields: " + + ", ".join(missing_proof) + ) + + if ( + _FINAL_DECISION_MARKED_RE.search(text) + and _is_none_value(review_mutations) + and not _ledger_mentions_review_submission(mcp_mutations) + and not _REVIEW_SUBMIT_BLOCKED_RE.search(text) + ): + reasons.append( + "final review decision marked but Review mutations ledger is empty" + ) + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "fields": fields, + } \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/blocked-review-handoff.md b/skills/llm-project-workflow/templates/blocked-review-handoff.md new file mode 100644 index 0000000..88cb764 --- /dev/null +++ b/skills/llm-project-workflow/templates/blocked-review-handoff.md @@ -0,0 +1,22 @@ +# Blocked review submission handoff + +Use when `gitea_submit_pr_review` or the terminal mutation gate blocks review +submission. + +```text +## Blocked Review Submission Handoff + +- Tool called: gitea_submit_pr_review +- Mutation attempted: yes +- Mutation rejected: yes +- No server-side state changed: confirmed +- Proof/source: +- Prior terminal mutation that consumed budget: +- Review decision (local intent): +- Server-side final decision marked: +- Gitea review submitted: no +- Gitea review blocked because: +- Review mutations: none (submission blocked) +- MCP/Gitea mutations: +- Safe next action: rewrite handoff with consistent ledger before posting +``` \ No newline at end of file diff --git a/tests/test_reviewer_handoff_consistency.py b/tests/test_reviewer_handoff_consistency.py new file mode 100644 index 0000000..ac83124 --- /dev/null +++ b/tests/test_reviewer_handoff_consistency.py @@ -0,0 +1,120 @@ +"""Tests for reviewer handoff consistency validation (#501).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import reviewer_handoff_consistency # noqa: E402 +from final_report_validator import assess_final_report_validator # noqa: E402 + + +def _base_handoff(**overrides): + lines = [ + "## Controller Handoff", + "", + "- Task: review PR #472", + "- Review decision: request_changes", + "- Review mutations: submitted request_changes review", + "- Merge mutations: none", + "- MCP/Gitea mutations: reviewer lease acquired", + ] + text = "\n".join(lines) + for key, value in overrides.items(): + token = f"- {key}:" + if token in text: + before, _, after = text.partition(token) + rest = after.split("\n", 1) + suffix = rest[1] if len(rest) > 1 else "" + text = f"{before}{token} {value}" + (f"\n{suffix}" if suffix else "") + else: + text += f"\n- {key}: {value}" + return text + + +class TestReviewerHandoffConsistency(unittest.TestCase): + def test_consistent_handoff_passes(self): + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency( + _base_handoff() + ) + self.assertTrue(result["proven"], result["reasons"]) + + def test_claimed_merge_missing_from_ledger(self): + report = _base_handoff( + **{ + "Current status": "Merged PR #411 earlier; reviewing PR #472", + "Merge mutations": "none", + "MCP/Gitea mutations": "reviewer lease acquired", + } + ) + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("merge" in r.lower() for r in result["reasons"])) + + def test_terminal_budget_consumed_without_merge_in_ledger(self): + report = _base_handoff( + **{ + "Current status": "Terminal mutation budget already consumed by merge", + "Merge mutations": "none", + "MCP/Gitea mutations": "reviewer lease acquired", + } + ) + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("terminal mutation budget" in r for r in result["reasons"])) + + def test_review_blocked_but_final_decision_marked(self): + report = _base_handoff( + **{ + "Current status": "gitea_submit_pr_review blocked", + "Review mutations": "none", + } + ) + report += "\nServer-side final decision marked via gitea_mark_final_review_decision." + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("blocked" in r and "final decision" in r for r in result["reasons"])) + + def test_lease_without_review_decision(self): + report = _base_handoff(**{"Review decision": "none"}) + report += "\nReviewer lease acquired for PR #472." + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("Review decision" in r for r in result["reasons"])) + + def test_rejected_mutation_without_proof(self): + report = _base_handoff( + **{ + "Review mutations": "none", + "MCP/Gitea mutations": "none", + } + ) + report += "\nReview submission blocked before mutation." + result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report) + self.assertFalse(result["proven"]) + self.assertTrue(any("proof fields" in r for r in result["reasons"])) + + def test_blocked_template_includes_required_fields(self): + template = reviewer_handoff_consistency.render_blocked_review_handoff_template() + for field in reviewer_handoff_consistency._BLOCKED_REVIEW_PROOF_FIELDS: + self.assertIn(field, template.lower()) + + def test_final_report_validator_integration(self): + report = _base_handoff( + **{ + "Current status": "Merged PR #411; terminal mutation budget consumed", + "Merge mutations": "none", + } + ) + result = assess_final_report_validator(report, "review_pr") + self.assertTrue( + any( + f["rule_id"] == "reviewer.handoff_consistency" + for f in result["findings"] + ), + result["findings"], + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 25cf323d143f56b7d2a959c5f6cb381c745c619f Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 03:44:58 -0400 Subject: [PATCH 3/8] feat: add Canonical Thread Handoff protocol (Closes #505) Define CTH comment types, base template, parser/validator helpers, protocol documentation with examples, and workflow/runbook/prompt updates so agents discover and post authoritative thread handoffs before acting. Co-Authored-By: Claude Opus 4.8 (1M context) --- canonical_thread_handoff.py | 213 ++++++++++++++++++ docs/canonical-thread-handoff.md | 142 ++++++++++++ docs/llm-workflow-runbooks.md | 21 ++ .../templates/canonical-thread-handoff.md | 36 +++ .../templates/merge-pr.md | 3 + .../templates/review-pr.md | 3 + .../templates/start-issue.md | 3 + .../workflows/review-merge-pr.md | 6 + .../workflows/work-issue.md | 6 + tests/test_canonical_thread_handoff.py | 139 ++++++++++++ 10 files changed, 572 insertions(+) create mode 100644 canonical_thread_handoff.py create mode 100644 docs/canonical-thread-handoff.md create mode 100644 skills/llm-project-workflow/templates/canonical-thread-handoff.md create mode 100644 tests/test_canonical_thread_handoff.py diff --git a/canonical_thread_handoff.py b/canonical_thread_handoff.py new file mode 100644 index 0000000..aa0a85b --- /dev/null +++ b/canonical_thread_handoff.py @@ -0,0 +1,213 @@ +"""Canonical Thread Handoff (CTH) protocol for issue and PR comments (#505). + +A CTH comment is the authoritative workflow handoff in a Gitea issue or PR +thread. It records current state, decisions, blockers, proof, and the exact +next prompt/action for the next LLM or person. +""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from typing import Any + +MARKER = "" + +CTH_TERM = "Canonical Thread Handoff" +CTH_ABBREV = "CTH" + +CTH_TYPES = frozenset({ + "State Handoff", + "Controller Decision", + "Author Handoff", + "Reviewer Handoff", + "Merger Handoff", + "Supersession Notice", + "Blocker", +}) + +_REQUIRED_BASE_FIELDS = ( + "status", + "next owner", + "current blocker", + "decision", + "proof", + "next action", + "ready-to-paste prompt", +) + +_HEADING_RE = re.compile( + r"^##\s+CTH:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_FIELD_RE = re.compile( + r"^([A-Za-z][A-Za-z0-9 /-]*):\s*(.+?)\s*$", + re.MULTILINE, +) + + +def format_cth_body( + *, + cth_type: str, + status: str, + next_owner: str, + current_blocker: str = "none", + decision: str = "none", + proof: str = "none", + next_action: str = "none", + ready_to_paste_prompt: str = "none", + extra_fields: dict[str, str] | None = None, +) -> str: + """Render a canonical CTH comment body.""" + normalized_type = (cth_type or "").strip() + if normalized_type not in CTH_TYPES: + raise ValueError( + f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}" + ) + lines = [ + MARKER, + f"## CTH: {normalized_type}", + "", + f"Status: {(status or 'unknown').strip() or 'unknown'}", + f"Next owner: {(next_owner or 'unknown').strip() or 'unknown'}", + f"Current blocker: {(current_blocker or 'none').strip() or 'none'}", + f"Decision: {(decision or 'none').strip() or 'none'}", + f"Proof: {(proof or 'none').strip() or 'none'}", + f"Next action: {(next_action or 'none').strip() or 'none'}", + f"Ready-to-paste prompt: {(ready_to_paste_prompt or 'none').strip() or 'none'}", + ] + for key, value in (extra_fields or {}).items(): + label = (key or "").strip() + if not label: + continue + lines.append(f"{label}: {(value or 'none').strip() or 'none'}") + return "\n".join(lines) + + +def parse_cth_comment(body: str) -> dict[str, Any] | None: + """Parse one CTH comment body, or None when not a CTH comment.""" + text = body or "" + if MARKER not in text and not _HEADING_RE.search(text): + return None + heading = _HEADING_RE.search(text) + if not heading: + return None + cth_type = heading.group(1).strip() + fields: dict[str, str] = {} + for match in _FIELD_RE.finditer(text): + key = match.group(1).strip().lower() + if key.startswith("cth"): + continue + fields[key] = match.group(2).strip() + return { + "cth_type": cth_type, + "fields": fields, + "raw_body": text, + } + + +def assess_cth_comment(body: str) -> dict[str, Any]: + """Validate a CTH comment has required base fields and a known type.""" + parsed = parse_cth_comment(body) + reasons: list[str] = [] + if not parsed: + return { + "valid": False, + "block": True, + "reasons": ["comment is not a Canonical Thread Handoff (CTH)"], + "parsed": None, + } + + cth_type = parsed.get("cth_type") or "" + if cth_type not in CTH_TYPES: + reasons.append( + f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}" + ) + + fields = parsed.get("fields") or {} + missing = [name for name in _REQUIRED_BASE_FIELDS if not (fields.get(name) or "").strip()] + if missing: + reasons.append( + "CTH missing required fields: " + ", ".join(missing) + ) + + vague_prompt = (fields.get("ready-to-paste prompt") or "").strip().lower() + if vague_prompt in {"", "none", "tbd", "n/a", "todo"}: + reasons.append("ready-to-paste prompt must be concrete and paste-ready") + + return { + "valid": not reasons, + "block": bool(reasons), + "reasons": reasons, + "parsed": parsed, + "cth_type": cth_type, + } + + +def find_latest_cth(comments: list[dict[str, Any]]) -> dict[str, Any] | None: + """Return the newest valid CTH comment from a Gitea comment list.""" + latest: dict[str, Any] | None = None + latest_ts: datetime | None = None + for comment in comments or []: + body = comment.get("body") or "" + parsed = parse_cth_comment(body) + if not parsed: + continue + created = _parse_timestamp(comment.get("created_at")) + if latest is None or (created and (latest_ts is None or created >= latest_ts)): + latest = { + "comment_id": comment.get("id"), + "created_at": comment.get("created_at"), + "author": (comment.get("user") or {}).get("login"), + **parsed, + } + latest_ts = created + return latest + + +def assess_cth_supersedes_non_cth( + *, + latest_cth: dict[str, Any] | None, + narrative_claim: str, +) -> dict[str, Any]: + """Fail closed when narrative relies on stale non-CTH state despite newer CTH.""" + if not latest_cth: + return {"block": False, "reasons": []} + text = (narrative_claim or "").strip() + if not text: + return {"block": False, "reasons": []} + if parse_cth_comment(text): + return {"block": False, "reasons": []} + return { + "block": True, + "reasons": [ + "workflow narrative must treat the latest CTH comment as authoritative; " + f"found newer CTH type '{latest_cth.get('cth_type')}' " + f"(comment #{latest_cth.get('comment_id')})" + ], + } + + +def _parse_timestamp(value: str | None) -> datetime | None: + if not value: + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +EXAMPLE_SCENARIOS = ( + "pr_approved_ready_for_merger", + "pr_request_changes_to_author", + "duplicate_superseded_pr_closure", + "blocked_dirty_root_worktree", + "stale_head_requires_fresh_review", + "issue_implementation_handoff", +) \ No newline at end of file diff --git a/docs/canonical-thread-handoff.md b/docs/canonical-thread-handoff.md new file mode 100644 index 0000000..0e45000 --- /dev/null +++ b/docs/canonical-thread-handoff.md @@ -0,0 +1,142 @@ +# Canonical Thread Handoff (CTH) + +**CTH** = **Canonical Thread Handoff** + +A CTH comment is the authoritative workflow handoff in a Gitea issue or PR +thread. It records current state, decisions, blockers, proof, and the exact +next prompt/action for the next LLM or person. + +CTH comments complement — but do not replace — formal Gitea review state. +A CTH may summarize an APPROVE or REQUEST_CHANGES decision, yet merge gates +still require the live Gitea review verdict. + +## CTH comment types + +- `CTH: State Handoff` +- `CTH: Controller Decision` +- `CTH: Author Handoff` +- `CTH: Reviewer Handoff` +- `CTH: Merger Handoff` +- `CTH: Supersession Notice` +- `CTH: Blocker` + +## Required base template + +```md +## CTH: + +Status: +Next owner: +Current blocker: +Decision: +Proof: +Next action: +Ready-to-paste prompt: +``` + +Extended fields may map to canonical issue/PR state templates from #495 when +that layer is available. Until then, keep the base fields complete. + +## Discovery and posting rules + +Before acting in an issue or PR thread: + +1. **Find the latest CTH comment** before doing work. +2. Treat the **latest valid CTH** as the current handoff state. +3. **Post a new CTH** when you finish, block, skip, supersede, request + changes, approve, or hand off. +4. Do **not** rely on stale non-CTH comments when a newer CTH exists. +5. Casual discussion comments do not need to be CTH comments. + +## Examples + +### PR approved and ready for merger + +```md +## CTH: Reviewer Handoff + +Status: approved_at_current_head +Next owner: merger +Current blocker: none +Decision: APPROVE recorded at head abc123... +Proof: gitea_submit_pr_review performed; visible verdict APPROVE +Next action: eligible merger merges PR #N with pinned expected_head_sha +Ready-to-paste prompt: Merge PR #N for issue #M if live head still abc123... and merge gates pass. +``` + +### PR request-changes back to author + +```md +## CTH: Reviewer Handoff + +Status: request_changes_at_current_head +Next owner: author +Current blocker: unresolved findings in validation report +Decision: REQUEST_CHANGES at head def456... +Proof: gitea_submit_pr_review performed; blocking review visible +Next action: author fixes findings and pushes; reviewer re-validates fresh head +Ready-to-paste prompt: Fix PR #N review findings, push to feat/issue-M-..., post Author Handoff CTH. +``` + +### Duplicate / superseded PR closure + +```md +## CTH: Supersession Notice + +Status: superseded +Next owner: controller +Current blocker: duplicate branch/PR work +Decision: close PR #N; continue on PR #M +Proof: duplicate gate linked open PR #M for issue #K +Next action: controller closes superseded PR and records canonical state +Ready-to-paste prompt: Close superseded PR #N; confirm PR #M remains canonical for issue #K. +``` + +### Blocked workflow due to dirty root/worktree + +```md +## CTH: Blocker + +Status: blocked_preflight +Next owner: operator +Current blocker: dirty control checkout / branches worktree mismatch +Decision: stop before mutation +Proof: verify_preflight_purity failed; workspace diagnostics attached +Next action: repair worktree or relaunch MCP from branches/ worktree +Ready-to-paste prompt: Repair dirty workspace, relaunch MCP from branches/review-prN, retry review. +``` + +### Stale head requiring fresh review + +```md +## CTH: Reviewer Handoff + +Status: stale_head +Next owner: reviewer +Current blocker: live head differs from pinned review head +Decision: prior approval not valid for current head +Proof: expected_head_sha mismatch at merge gate +Next action: re-run validation and post fresh review decision +Ready-to-paste prompt: Re-validate PR #N at live head, dry-run review gates, submit fresh verdict. +``` + +### Issue implementation handoff + +```md +## CTH: Author Handoff + +Status: implementation_complete_pending_review +Next owner: reviewer +Current blocker: none +Decision: PR #N ready for review at head fedcba... +Proof: tests passed in branches/issue-M-...; PR opened with Closes #M +Next action: reviewer acquires lease and validates PR #N +Ready-to-paste prompt: Review PR #N for issue #M; pin head fedcba... before mutations. +``` + +## Related + +- [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) +- [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md) +- #495 — canonical next-action comment fields +- #496 — fail-closed validation for workflow-changing comments \ No newline at end of file diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index d67769d..bba69c9 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -728,6 +728,27 @@ Never imply full-suite success unless the full-suite command itself passed (`full_suite_passed: true`). A report that hides a failed or skipped check is worse than a failing report. +## Canonical Thread Handoff (CTH) + +**CTH** = **Canonical Thread Handoff** — the authoritative workflow handoff +comment in a Gitea issue or PR thread. See +[`canonical-thread-handoff.md`](canonical-thread-handoff.md) for types, +templates, and examples. + +Before acting in an issue or PR thread: + +1. **Find the latest CTH comment** before doing work. +2. Treat the **latest valid CTH** as the current handoff state. +3. **Post a new CTH** when you finish, block, skip, supersede, request + changes, approve, or hand off. +4. Do **not** rely on stale non-CTH comments when a newer CTH exists. + +A CTH summarizes workflow state for the next session. Formal Gitea review +verdicts remain authoritative for merge gates — a CTH is not merge approval +by itself. + +Template: [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md) + ## Controller Handoff (required, every task) Every task — implementation, review, merge, triage, documentation, diff --git a/skills/llm-project-workflow/templates/canonical-thread-handoff.md b/skills/llm-project-workflow/templates/canonical-thread-handoff.md new file mode 100644 index 0000000..37a76e5 --- /dev/null +++ b/skills/llm-project-workflow/templates/canonical-thread-handoff.md @@ -0,0 +1,36 @@ +# Template: Canonical Thread Handoff (CTH) + +Copy, fill the fields, and post as an issue or PR comment. + +```text +## CTH: + +Status: +Next owner: +Current blocker: +Decision: +Proof: +Next action: +Ready-to-paste prompt: +``` + +Allowed `` values: + +- State Handoff +- Controller Decision +- Author Handoff +- Reviewer Handoff +- Merger Handoff +- Supersession Notice +- Blocker + +Rules: + +- Find the latest CTH comment in the thread before starting work. +- Treat the latest valid CTH as the current source of truth. +- Post a new CTH when you finish, block, skip, supersede, approve, request + changes, or hand off. +- A CTH summarizes workflow state; formal Gitea review verdicts remain + authoritative for merge gates. + +Full protocol: `docs/canonical-thread-handoff.md` \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/merge-pr.md b/skills/llm-project-workflow/templates/merge-pr.md index ae99544..f891510 100644 --- a/skills/llm-project-workflow/templates/merge-pr.md +++ b/skills/llm-project-workflow/templates/merge-pr.md @@ -10,6 +10,9 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- Find the latest CTH comment on the PR/issue thread before starting work. + Post a new CTH: Merger Handoff (or CTH: Blocker) at session end. + Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md - Only an eligible, NON-author reviewer merges. If authenticated user == PR author → STOP. - Do not merge unless the PR is open, mergeable, and its checks/review pass. diff --git a/skills/llm-project-workflow/templates/review-pr.md b/skills/llm-project-workflow/templates/review-pr.md index 391f962..3a5ded8 100644 --- a/skills/llm-project-workflow/templates/review-pr.md +++ b/skills/llm-project-workflow/templates/review-pr.md @@ -35,6 +35,9 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- Find the latest CTH comment on the PR/issue thread before starting work. + Post a new CTH: Reviewer Handoff (or CTH: Blocker) at session end. + Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md - Review in a SEPARATE detached review worktree, never the author's folder. - Worktree safety (#233): before checkout, diff, validation, review, or merge, report the starting worktree path and whether it was dirty. If unrelated diff --git a/skills/llm-project-workflow/templates/start-issue.md b/skills/llm-project-workflow/templates/start-issue.md index 4b8c159..b483d18 100644 --- a/skills/llm-project-workflow/templates/start-issue.md +++ b/skills/llm-project-workflow/templates/start-issue.md @@ -10,6 +10,9 @@ Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue) Rules (llm-project-workflow): +- Find the latest CTH comment on the issue/PR thread before starting work. + Post a new CTH: Author Handoff (or CTH: Blocker) at session end. + Template: skills/llm-project-workflow/templates/canonical-thread-handoff.md - No repo changes without a tracking issue. If none exists, create one first; if it can't be created, stop. - Work only in an isolated branch worktree under branches/. The main checkout diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 1c4e9be..c620447 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -26,6 +26,12 @@ workflow rules exactly. ## 0. Load the canonical workflow first +Before starting PR work, **find the latest CTH comment** on the PR or linked +issue thread. Treat that CTH as the current handoff state. Post a new +**CTH: Reviewer Handoff**, **CTH: Merger Handoff**, **CTH: Blocker**, or +**CTH: Supersession Notice** when you finish, block, skip, supersede, +approve, request changes, or hand off. See `docs/canonical-thread-handoff.md`. + Before starting PR work, check whether the project provides a canonical PR review/merge workflow through a project skill, runbook, or MCP helper. If available, load it first and report: diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md index 6766523..8bc0c31 100644 --- a/skills/llm-project-workflow/workflows/work-issue.md +++ b/skills/llm-project-workflow/workflows/work-issue.md @@ -26,6 +26,12 @@ This is an author/coder workflow. It is not a reviewer workflow. ## 0. Load the canonical workflow first +Before starting issue work, **find the latest CTH comment** on the target +issue or linked PR thread. Treat that CTH as the current handoff state unless +a newer authoritative gate supersedes it. Post a new **CTH: Author Handoff** +(or `CTH: Blocker`) when you finish, block, or hand off. +See `docs/canonical-thread-handoff.md`. + Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper. If available, load it first and report: diff --git a/tests/test_canonical_thread_handoff.py b/tests/test_canonical_thread_handoff.py new file mode 100644 index 0000000..96b6882 --- /dev/null +++ b/tests/test_canonical_thread_handoff.py @@ -0,0 +1,139 @@ +"""Tests for Canonical Thread Handoff (CTH) protocol (#505).""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import canonical_thread_handoff as cth # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent +RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md" +PROTOCOL_DOC = REPO_ROOT / "docs" / "canonical-thread-handoff.md" +TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "canonical-thread-handoff.md" +REVIEW_WORKFLOW = REPO_ROOT / "skills" / "llm-project-workflow/workflows" / "review-merge-pr.md" +WORK_WORKFLOW = REPO_ROOT / "skills" / "llm-project-workflow/workflows" / "work-issue.md" +REVIEW_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "review-pr.md" +START_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "start-issue.md" +MERGE_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "merge-pr.md" + + +class TestCthModule(unittest.TestCase): + def test_defines_cth_term_and_types(self): + self.assertEqual(cth.CTH_ABBREV, "CTH") + self.assertIn("State Handoff", cth.CTH_TYPES) + self.assertIn("Reviewer Handoff", cth.CTH_TYPES) + self.assertIn("Blocker", cth.CTH_TYPES) + + def test_format_and_parse_round_trip(self): + body = cth.format_cth_body( + cth_type="Author Handoff", + status="implementation_complete", + next_owner="reviewer", + current_blocker="none", + decision="PR opened", + proof="pytest passed", + next_action="review PR #9", + ready_to_paste_prompt="Review PR #9 for issue #5", + ) + parsed = cth.parse_cth_comment(body) + self.assertIsNotNone(parsed) + self.assertEqual(parsed["cth_type"], "Author Handoff") + self.assertEqual(parsed["fields"]["status"], "implementation_complete") + self.assertEqual(parsed["fields"]["next owner"], "reviewer") + + def test_valid_cth_passes_assessment(self): + body = cth.format_cth_body( + cth_type="Reviewer Handoff", + status="approved", + next_owner="merger", + current_blocker="none", + decision="APPROVE", + proof="review id 42", + next_action="merge if gates pass", + ready_to_paste_prompt="Merge PR #12 with pinned head abcdef0123456789abcdef0123456789abcdef01", + ) + result = cth.assess_cth_comment(body) + self.assertFalse(result["block"]) + self.assertTrue(result["valid"]) + + def test_missing_fields_fail_closed(self): + body = "## CTH: Blocker\n\nStatus: blocked\n" + result = cth.assess_cth_comment(body) + self.assertTrue(result["block"]) + self.assertIn("missing required fields", result["reasons"][0]) + + def test_find_latest_cth_prefers_newer_comment(self): + older = cth.format_cth_body( + cth_type="State Handoff", + status="old", + next_owner="author", + current_blocker="none", + decision="old", + proof="old", + next_action="old", + ready_to_paste_prompt="old prompt text here", + ) + newer = cth.format_cth_body( + cth_type="Reviewer Handoff", + status="new", + next_owner="merger", + current_blocker="none", + decision="approve", + proof="new", + next_action="merge", + ready_to_paste_prompt="merge PR #3 now with pinned head", + ) + comments = [ + {"id": 1, "created_at": "2026-07-01T10:00:00Z", "body": older}, + {"id": 2, "created_at": "2026-07-02T10:00:00Z", "body": newer}, + ] + latest = cth.find_latest_cth(comments) + self.assertEqual(latest["comment_id"], 2) + self.assertEqual(latest["cth_type"], "Reviewer Handoff") + + def test_cth_does_not_replace_formal_reviews_documented_in_protocol(self): + text = PROTOCOL_DOC.read_text(encoding="utf-8") + self.assertIn("do not replace", text.lower()) + self.assertIn("formal Gitea review", text) + + def test_examples_cover_required_scenarios(self): + text = PROTOCOL_DOC.read_text(encoding="utf-8") + expected_phrases = ( + "PR approved and ready for merger", + "request-changes back to author", + "superseded PR closure", + "dirty root/worktree", + "Stale head requiring fresh review", + "Issue implementation handoff", + ) + for phrase in expected_phrases: + self.assertIn(phrase, text) + + +class TestCthDocumentationChecks(unittest.TestCase): + def test_runbook_references_cth_discovery_and_posting(self): + text = RUNBOOK.read_text(encoding="utf-8") + self.assertIn("Canonical Thread Handoff", text) + self.assertIn("Find the latest CTH comment", text) + self.assertIn("Post a new CTH", text) + self.assertIn("canonical-thread-handoff.md", text) + + def test_workflows_reference_cth_rules(self): + for path in (REVIEW_WORKFLOW, WORK_WORKFLOW): + text = path.read_text(encoding="utf-8") + self.assertIn("latest CTH comment", text) + self.assertIn("canonical-thread-handoff.md", text) + + def test_prompt_templates_reference_cth_rules(self): + for path in (REVIEW_TEMPLATE, START_TEMPLATE, MERGE_TEMPLATE, TEMPLATE): + text = path.read_text(encoding="utf-8") + self.assertIn("CTH", text) + self.assertIn("canonical-thread-handoff", text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From b7755f26e3709d7c73dda389e8124e1e55e8df36 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 04:20:24 -0400 Subject: [PATCH 4/8] feat: require workflow labels for issues (Closes #513) --- create_issue.py | 54 +++++- docs/label-taxonomy.md | 112 +++++++++--- docs/llm-workflow-runbooks.md | 15 +- gitea_mcp_server.py | 264 +++++++++++++++++++++++++-- issue_workflow_labels.py | 194 ++++++++++++++++++++ manage_labels.py | 7 +- task_capability_map.py | 5 + tests/test_create_issue.py | 31 ++++ tests/test_issue_workflow_labels.py | 73 ++++++++ tests/test_issue_write_tool_gates.py | 11 +- tests/test_manage_labels.py | 6 + tests/test_mcp_server.py | 139 ++++++++++++-- 12 files changed, 838 insertions(+), 73 deletions(-) create mode 100644 issue_workflow_labels.py create mode 100644 tests/test_issue_workflow_labels.py diff --git a/create_issue.py b/create_issue.py index 6ec1b4b..5a93532 100644 --- a/create_issue.py +++ b/create_issue.py @@ -29,6 +29,7 @@ from gitea_auth import ( get_credentials, resolve_remote, add_remote_args, api_request, repo_api_url, ) +import issue_workflow_labels def main(argv=None): @@ -38,6 +39,16 @@ def main(argv=None): parser.add_argument("--body", default="", help="Issue body text.") parser.add_argument("--body-file", help="Read issue body from this file ('-' for stdin).") + parser.add_argument("--label", action="append", default=[], + help="Existing label name to apply; may be repeated.") + parser.add_argument("--type-label", + help="Issue type, e.g. feature or type:feature.") + parser.add_argument("--status-label", + help="Initial status, e.g. ready or status:ready.") + parser.add_argument("--discussion", action="store_true", + help="Apply type:discussion.") + parser.add_argument("--require-workflow-labels", action="store_true", + help="Fail unless one type:* and one status:* label are supplied.") args = parser.parse_args(argv) host, org, repo = resolve_remote(args) @@ -50,6 +61,24 @@ def main(argv=None): with open(args.body_file, "r", encoding="utf-8") as fh: body = fh.read() + requested_labels = issue_workflow_labels.labels_for_new_issue( + issue_type=args.type_label, + initial_status=args.status_label, + extra_labels=args.label, + discussion=args.discussion, + ) + label_assessment = issue_workflow_labels.assess_issue_labels( + requested_labels, + discussion=args.discussion, + ) + if args.require_workflow_labels and not label_assessment["valid"]: + print( + "Workflow label validation failed: " + + "; ".join(label_assessment["errors"]), + file=sys.stderr, + ) + return 1 + user, password = get_credentials(host) if not user or not password: print(f"Could not get credentials for {host} " @@ -59,11 +88,34 @@ def main(argv=None): import base64 auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}" - url = f"{repo_api_url(host, org, repo)}/issues" + base = repo_api_url(host, org, repo) + url = f"{base}/issues" try: + label_ids = [] + if requested_labels: + existing = api_request("GET", f"{base}/labels", auth) + by_name = {lb["name"]: lb["id"] for lb in existing} + missing = [name for name in requested_labels if name not in by_name] + if missing: + print(f"Missing labels: {missing}", file=sys.stderr) + return 1 + label_ids = [by_name[name] for name in requested_labels] data = api_request("POST", url, auth, {"title": args.title, "body": body}) + if label_ids: + api_request( + "PUT", + f"{base}/issues/{data.get('number')}/labels", + auth, + {"labels": label_ids}, + ) print(f"Issue #{data.get('number')}: {data.get('html_url')}") + if not label_assessment["valid"]: + print( + "Workflow label recommendation: " + + "; ".join(label_assessment["errors"]), + file=sys.stderr, + ) return 0 except RuntimeError as e: print(f"Error: {e}", file=sys.stderr) diff --git a/docs/label-taxonomy.md b/docs/label-taxonomy.md index e3cf98b..d1c400d 100644 --- a/docs/label-taxonomy.md +++ b/docs/label-taxonomy.md @@ -1,34 +1,98 @@ # Label Taxonomy -This document catalogs the issue labels used for MCP workflows, including Jenkins and GlitchTip (observability). +This document defines the canonical issue labels used by MCP workflows. -> **Approval Required:** Do not create or apply new labels in `manage_labels.py` without explicit owner approval of this document. +Every issue should carry: -## Existing Labels +- one `type:*` label +- one `status:*` label -* **`jenkins`** - * Description: Jenkins integration - * Color: `d93f0b` - * Use: Used to mark issues, PRs, or tasks that involve the `jenkins-mcp` boundaries, CI/CD designs, or build failures. +Discussion-only issues must carry `type:discussion`. -* **`glitchtip`** - * Description: GlitchTip integration - * Color: `b60205` - * Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration. +## Issue Type Labels -## Proposed / Missing Labels +| Label | Use | +| --- | --- | +| `type:bug` | Bug or defect | +| `type:feature` | Feature or enhancement | +| `type:process` | Process or policy work | +| `type:workflow` | Workflow automation or guidance | +| `type:guardrail` | Safety gate or guardrail | +| `type:docs` | Documentation work | +| `type:test` | Tests or test infrastructure | +| `type:discussion` | Discussion-only issue | +| `type:umbrella` | Umbrella or tracker issue | +| `type:cleanup` | Cleanup or hygiene work | -* **`observability`** - * Proposed Description: Observability, metrics, and monitoring tasks - * Proposed Color: `5319e7` - * Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements. +## Workflow Status Labels -* **`source:glitchtip`** - * Proposed Description: Issue filed automatically by GlitchTip orchestration - * Proposed Color: `b60205` - * Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue. +Only one `status:*` label should be active on an issue at a time. When an issue +moves forward, tooling must remove the old `status:*` label and apply the new +one. -* **`status:triage`** - * Proposed Description: Issue needs human or orchestrator triage - * Proposed Color: `fbca04` - * Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution. +| Label | Use | +| --- | --- | +| `status:triage` | Issue needs triage | +| `status:ready` | Issue is ready for work | +| `status:claimed` | Issue is claimed | +| `status:in-progress` | Issue is being worked on | +| `status:blocked` | Issue is blocked | +| `status:needs-review` | Issue work needs review | +| `status:pr-open` | A linked PR is open | +| `status:approved` | Linked PR is approved | +| `status:merged` | Linked PR is merged | +| `status:reconcile` | Issue needs reconciliation | +| `status:done` | Issue workflow is complete | +| `status:duplicate` | Issue is a duplicate | +| `status:wontfix` | Issue will not be fixed | + +## Transition Rules + +Suggested lifecycle: + +1. New issue created: `status:triage` or `status:ready` +2. Issue selected by an author: `status:claimed` +3. Author starts work: `status:in-progress` +4. Work is blocked: `status:blocked` +5. PR opened: `status:pr-open` +6. PR approved: `status:approved` +7. PR merged but issue still needs closure/reconciliation: `status:reconcile` +8. Issue fully complete: `status:done` +9. Duplicate issue: `status:duplicate` +10. Won't-fix issue: `status:wontfix` + +The helper module `issue_workflow_labels.py` is the source of truth for the +canonical label specs and status transition replacement behavior. + +## Discussion Issues + +Discussion issues must be labeled `type:discussion`. + +A discussion issue should not be treated as implementation-ready unless it also +has a clear implementation status and next action. + +If a discussion produces implementation work, either: + +1. convert the discussion issue into an implementation issue by changing labels + and adding acceptance criteria, or +2. create child implementation issues and leave the discussion issue as + `type:discussion`. + +## Tooling + +- `manage_labels.py --create-labels` creates the canonical `type:*` and + `status:*` labels. +- `gitea_create_issue` recommends `type:*` and `status:*` labels when missing + and can apply supplied label names. +- `gitea_mark_issue(..., action="start")` replaces old `status:*` labels with + `status:in-progress`. +- `gitea_create_pr` fails closed before PR creation if `status:pr-open` cannot + be applied to the locked issue, then applies it after the PR is created. +- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author + sessions can satisfy the branches-only mutation guard while changing labels. + +## Existing Non-Workflow Labels + +Existing non-workflow labels such as `mcp`, `workflow`, `labels`, `tracker`, +`jenkins`, `glitchtip`, `documentation`, and `testing` remain valid topical +labels. They do not replace the required `type:*` and `status:*` labels. diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index d67769d..fb77ddf 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -581,19 +581,24 @@ loop and do **not** substitute WebFetch/Playwright/manual base64. - **Profile:** issue-manager or author (any profile allowed to create issues). - **Steps:** create the parent/roadmap issue; create child issues; apply the minimal label set; link children to the parent. +- **Labels:** new issues should carry one `type:*` label and one `status:*` + label. Discussion-only issues must carry `type:discussion`. See + [`label-taxonomy.md`](label-taxonomy.md). - **Prompt:** `Using the issue-manager profile, create issue "" with body <body>, then create child issues for <list> and link them to the parent.` ### Implement an issue and open a PR - **Profile:** author. -- **Steps:** claim the issue (`status:in-progress`); create an isolated branch - worktree from latest `master` under `branches/` (`feat/issue-<n>-...` / +- **Steps:** claim the issue (`status:in-progress`, replacing any old + `status:*` label); create an isolated branch worktree from latest `master` + under `branches/` (`feat/issue-<n>-...` / `fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or update tests if behavior changes; run the full suite; commit with an - issue-linked message; open a PR to `master`. **Do not** review or merge your - own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in - the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md). + issue-linked message; open a PR to `master`; move the issue to + `status:pr-open`. **Do not** review or merge your own PR. Include an + `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see + [`llm-agent-sha.md`](llm-agent-sha.md). - **Prompt:** `Use an author profile to implement issue #N and open a PR to master. Do not self-review or self-merge.` diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 411a80b..4a83deb 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -608,6 +608,7 @@ import already_landed_reconcile # noqa: E402 import author_mutation_worktree # noqa: E402 import issue_claim_heartbeat # noqa: E402 import issue_work_duplicate_gate # noqa: E402 +import issue_workflow_labels # noqa: E402 import reviewer_pr_lease # noqa: E402 import merged_cleanup_reconcile # noqa: E402 import reconciler_profile # noqa: E402 @@ -954,6 +955,108 @@ def extract_linked_issue_numbers(text: str | None, branch_name: str | None = Non issues.update(int(m) for m in pattern.findall(branch_name)) return sorted(list(issues)) +def _repo_label_id_map(base: str, auth: str) -> dict[str, int]: + labels = api_get_all(f"{base}/labels", auth) or [] + return { + str(lb["name"]): int(lb["id"]) + for lb in labels + if isinstance(lb, dict) and lb.get("name") and lb.get("id") is not None + } + +def _issue_label_names(base: str, auth: str, issue_number: int) -> list[str]: + issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {} + return issue_workflow_labels.label_names(issue) + +def _put_issue_label_names( + *, + base: str, + auth: str, + issue_number: int, + names: list[str], + label_ids_by_name: dict[str, int] | None = None, +) -> list[dict]: + by_name = label_ids_by_name or _repo_label_id_map(base, auth) + missing = [name for name in names if name not in by_name] + if missing: + raise RuntimeError( + f"The following labels do not exist on the repository: {missing}. " + "Create the canonical workflow labels first." + ) + ids = [by_name[name] for name in names] + return api_request( + "PUT", + f"{base}/issues/{issue_number}/labels", + auth, + {"labels": ids}, + ) + +def _transition_issue_status( + *, + base: str, + auth: str, + issue_number: int, + status: str, + label_ids_by_name: dict[str, int] | None = None, +) -> dict: + by_name = label_ids_by_name or _repo_label_id_map(base, auth) + target_status = issue_workflow_labels.canonical_status_label(status) + if target_status not in by_name: + return { + "success": False, + "performed": False, + "issue_number": issue_number, + "target_status": target_status, + "reasons": [ + f"required workflow status label '{target_status}' does not exist" + ], + } + current = _issue_label_names(base, auth, issue_number) + updated = issue_workflow_labels.transition_status_labels(current, target_status) + _put_issue_label_names( + base=base, + auth=auth, + issue_number=issue_number, + names=updated, + label_ids_by_name=by_name, + ) + return { + "success": True, + "performed": True, + "issue_number": issue_number, + "target_status": target_status, + "labels": updated, + } + +def _prepare_issue_status_transition( + *, + base: str, + auth: str, + issue_number: int, + status: str, +) -> dict: + by_name = _repo_label_id_map(base, auth) + target_status = issue_workflow_labels.canonical_status_label(status) + if target_status not in by_name: + return { + "success": False, + "performed": False, + "issue_number": issue_number, + "target_status": target_status, + "reasons": [ + f"required workflow status label '{target_status}' does not exist" + ], + } + current = _issue_label_names(base, auth, issue_number) + updated = issue_workflow_labels.transition_status_labels(current, target_status) + return { + "success": True, + "performed": False, + "issue_number": issue_number, + "target_status": target_status, + "labels": updated, + "label_ids_by_name": by_name, + } + def release_in_progress_label(issue_numbers: list[int], remote: str, host: str | None, org: str | None, repo: str | None) -> dict: if not issue_numbers: return {} @@ -1248,6 +1351,11 @@ def gitea_create_issue( host: str | None = None, org: str | None = None, repo: str | None = None, + labels: list[str] | None = None, + issue_type: str | None = None, + initial_status: str | None = None, + discussion: bool = False, + require_workflow_labels: bool = False, allow_duplicate_override: bool = False, split_from_issue: int | None = None, worktree_path: str | None = None, @@ -1261,6 +1369,12 @@ def gitea_create_issue( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + labels: Optional labels to apply by name after creating the issue. + issue_type: Optional canonical type, e.g. 'feature' or 'type:feature'. + initial_status: Optional initial workflow status, e.g. 'ready'. + discussion: If True, require/apply type:discussion. + require_workflow_labels: If True, fail closed unless labels include one + type:* and one status:* label. allow_duplicate_override: Operator-approved split after duplicate found. split_from_issue: Existing duplicate issue number when overriding. worktree_path: Optional path to verify branches-only guard. @@ -1292,6 +1406,40 @@ def gitea_create_issue( return blocked verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue") base = repo_api_url(h, o, r) + requested_labels = issue_workflow_labels.labels_for_new_issue( + issue_type=issue_type, + initial_status=initial_status, + extra_labels=labels, + discussion=discussion, + ) + label_assessment = issue_workflow_labels.assess_issue_labels( + requested_labels, + discussion=discussion, + require_type=True, + require_status=True, + ) + if require_workflow_labels and not label_assessment["valid"]: + return { + "success": False, + "performed": False, + "number": None, + "workflow_label_validation": label_assessment, + "reasons": label_assessment["errors"], + } + label_ids_by_name: dict[str, int] | None = None + if requested_labels: + label_ids_by_name = _repo_label_id_map(base, auth) + missing = [name for name in requested_labels if name not in label_ids_by_name] + if missing: + return { + "success": False, + "performed": False, + "number": None, + "workflow_label_validation": label_assessment, + "reasons": [ + f"requested labels do not exist on the repository: {missing}" + ], + } open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth) closed_issues = api_get_all( f"{base}/issues?state=closed&type=issues", auth, limit=100 @@ -1326,7 +1474,29 @@ def gitea_create_issue( _audit("create_issue", host=h, remote=remote, org=o, repo=r, result=gitea_audit.SUCCEEDED, issue_number=data["number"], request_metadata={"title": title}, mutation_task="create_issue") - return _with_optional_url({"number": data["number"]}, data.get("html_url")) + result = { + "number": data["number"], + "workflow_label_validation": label_assessment, + } + if requested_labels: + with _audited( + "set_issue_labels", + host=h, + remote=remote, + org=o, + repo=r, + issue_number=data["number"], + request_metadata={"labels": requested_labels, "source": "create_issue"}, + ): + applied = _put_issue_label_names( + base=base, + auth=auth, + issue_number=data["number"], + names=requested_labels, + label_ids_by_name=label_ids_by_name, + ) + result["labels"] = issue_workflow_labels.label_names(applied) + return _with_optional_url(result, data.get("html_url")) @mcp.tool() @@ -1735,7 +1905,23 @@ def gitea_create_pr( ) auth = _auth(h) - url = f"{repo_api_url(h, o, r)}/pulls" + repo_base = repo_api_url(h, o, r) + pr_open_transition = _prepare_issue_status_transition( + base=repo_base, + auth=auth, + issue_number=int(locked_issue), + status="status:pr-open", + ) + if not pr_open_transition.get("success"): + return { + "success": False, + "performed": False, + "number": None, + "issue_number": locked_issue, + "issue_status_transition": pr_open_transition, + "reasons": pr_open_transition.get("reasons", []), + } + url = f"{repo_base}/pulls" payload = {"title": title, "body": body, "head": head, "base": base} meta = {"title": title, "head": head, "base": base} try: @@ -1750,7 +1936,37 @@ def gitea_create_pr( result=gitea_audit.SUCCEEDED, pr_number=data["number"], target_branch=head, request_metadata=meta, mutation_task="create_pr") - return _with_optional_url({"number": data["number"]}, data.get("html_url")) + with _audited( + "set_issue_labels", + host=h, + remote=remote, + org=o, + repo=r, + issue_number=int(locked_issue), + request_metadata={ + "source": "create_pr", + "status": "status:pr-open", + "pr_number": data["number"], + }, + ): + _put_issue_label_names( + base=repo_base, + auth=auth, + issue_number=int(locked_issue), + names=pr_open_transition["labels"], + label_ids_by_name=pr_open_transition["label_ids_by_name"], + ) + result = { + "number": data["number"], + "issue_status_transition": { + "success": True, + "performed": True, + "issue_number": int(locked_issue), + "target_status": "status:pr-open", + "labels": pr_open_transition["labels"], + }, + } + return _with_optional_url(result, data.get("html_url")) def _format_list_pr_entry(pr: dict) -> dict: @@ -6870,15 +7086,8 @@ def gitea_mark_issue( auth = _auth(h) base = repo_api_url(h, o, r) - # Find the status:in-progress label id - labels = api_request("GET", f"{base}/labels?limit=100", auth) - label_id = None - for lb in labels: - if lb["name"] == "status:in-progress": - label_id = lb["id"] - break - - if label_id is None: + label_ids_by_name = _repo_label_id_map(base, auth) + if "status:in-progress" not in label_ids_by_name: raise RuntimeError( "Label 'status:in-progress' not found. " "Run manage_labels.py to create it first." @@ -6886,10 +7095,19 @@ def gitea_mark_issue( if action == "start": with _audited("label_issue", host=h, remote=remote, org=o, repo=r, - issue_number=issue_number, - request_metadata={"op": "add", "label": "status:in-progress"}): - api_request("POST", f"{base}/issues/{issue_number}/labels", auth, - {"labels": [label_id]}) + issue_number=issue_number, request_metadata={ + "op": "replace-status", + "label": "status:in-progress", + }): + transition = _transition_issue_status( + base=base, + auth=auth, + issue_number=issue_number, + status="status:in-progress", + label_ids_by_name=label_ids_by_name, + ) + if not transition.get("success"): + raise RuntimeError("; ".join(transition.get("reasons") or [])) active_profile = (profile or get_profile().get("profile_name") or "unknown") branch = (branch_name or "pending").strip() or "pending" heartbeat_body = issue_claim_heartbeat.format_heartbeat_body( @@ -6914,8 +7132,10 @@ def gitea_mark_issue( "message": f"Issue #{issue_number} claimed.", "heartbeat_posted": heartbeat.get("success", False), "heartbeat_comment_id": heartbeat.get("comment_id"), + "status_transition": transition, } else: + label_id = label_ids_by_name["status:in-progress"] with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r, issue_number=issue_number, request_metadata={"op": "remove", "label": "status:in-progress"}): @@ -7281,6 +7501,7 @@ def gitea_create_label( host: str | None = None, org: str | None = None, repo: str | None = None, + worktree_path: str | None = None, ) -> dict: """Create a new label on a Gitea repository. @@ -7292,11 +7513,16 @@ def gitea_create_label( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + worktree_path: Optional branches worktree to verify before mutation. Returns: dict containing the created label details. """ - verify_preflight_purity(remote) + blocked = _profile_permission_block( + task_capability_map.required_permission("create_label")) + if blocked: + return blocked + verify_preflight_purity(remote, worktree_path=worktree_path, task="create_label") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) base = repo_api_url(h, o, r) @@ -7322,6 +7548,7 @@ def gitea_set_issue_labels( host: str | None = None, org: str | None = None, repo: str | None = None, + worktree_path: str | None = None, ) -> list: """Replace all labels on a Gitea issue with a new list of label names. @@ -7332,6 +7559,7 @@ def gitea_set_issue_labels( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + worktree_path: Optional branches worktree to verify before mutation. Returns: list of all labels currently applied to the issue. @@ -7340,7 +7568,7 @@ def gitea_set_issue_labels( task_capability_map.required_permission("set_issue_labels")) if blocked: return blocked - verify_preflight_purity(remote, task="set_issue_labels") + verify_preflight_purity(remote, worktree_path=worktree_path, task="set_issue_labels") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) base = repo_api_url(h, o, r) diff --git a/issue_workflow_labels.py b/issue_workflow_labels.py new file mode 100644 index 0000000..1e9c491 --- /dev/null +++ b/issue_workflow_labels.py @@ -0,0 +1,194 @@ +"""Canonical issue type/status label taxonomy and transition helpers (#513).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Mapping, Sequence + + +@dataclass(frozen=True) +class LabelSpec: + name: str + color: str + description: str + + +TYPE_LABEL_SPECS: tuple[LabelSpec, ...] = ( + LabelSpec("type:bug", "b60205", "Bug or defect"), + LabelSpec("type:feature", "a2eeef", "Feature or enhancement"), + LabelSpec("type:process", "5319e7", "Process or policy work"), + LabelSpec("type:workflow", "c2e0c6", "Workflow automation or guidance"), + LabelSpec("type:guardrail", "d93f0b", "Safety gate or guardrail"), + LabelSpec("type:docs", "006b75", "Documentation work"), + LabelSpec("type:test", "0e8a16", "Tests or test infrastructure"), + LabelSpec("type:discussion", "bfdadc", "Discussion-only issue"), + LabelSpec("type:umbrella", "c5def5", "Umbrella or tracker issue"), + LabelSpec("type:cleanup", "ededed", "Cleanup or hygiene work"), +) + +STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = ( + LabelSpec("status:triage", "fbca04", "Issue needs triage"), + LabelSpec("status:ready", "0e8a16", "Issue is ready for work"), + LabelSpec("status:claimed", "fefe2e", "Issue is claimed"), + LabelSpec("status:in-progress", "fefe2e", "Issue is being worked on"), + LabelSpec("status:blocked", "b60205", "Issue is blocked"), + LabelSpec("status:needs-review", "0052cc", "Issue work needs review"), + LabelSpec("status:pr-open", "1d76db", "A linked PR is open"), + LabelSpec("status:approved", "0e8a16", "Linked PR is approved"), + LabelSpec("status:merged", "5319e7", "Linked PR is merged"), + LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"), + LabelSpec("status:done", "0e8a16", "Issue workflow is complete"), + LabelSpec("status:duplicate", "cccccc", "Issue is a duplicate"), + LabelSpec("status:wontfix", "000000", "Issue will not be fixed"), +) + +CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = ( + TYPE_LABEL_SPECS + STATUS_LABEL_SPECS +) + +TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS) +STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS) +CANONICAL_LABELS: frozenset[str] = frozenset( + spec.name for spec in CANONICAL_LABEL_SPECS +) + +STATUS_TRANSITIONS: dict[str, str] = { + "triage": "status:triage", + "ready": "status:ready", + "claim": "status:claimed", + "claimed": "status:claimed", + "start": "status:in-progress", + "start_work": "status:in-progress", + "in_progress": "status:in-progress", + "in-progress": "status:in-progress", + "block": "status:blocked", + "blocked": "status:blocked", + "needs_review": "status:needs-review", + "needs-review": "status:needs-review", + "pr_open": "status:pr-open", + "pr-open": "status:pr-open", + "approved": "status:approved", + "merge": "status:reconcile", + "merged": "status:reconcile", + "reconcile": "status:reconcile", + "done": "status:done", + "complete": "status:done", + "duplicate": "status:duplicate", + "wontfix": "status:wontfix", +} + + +def label_name(label: str | Mapping[str, object]) -> str: + """Return a normalized label name from a Gitea label object or string.""" + if isinstance(label, str): + return label.strip() + value = label.get("name") + return str(value or "").strip() + + +def label_names(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: + """Extract label names from a label list or issue-like mapping.""" + raw: object + if isinstance(labels, Mapping): + raw = labels.get("labels", []) + else: + raw = labels + return [name for name in (label_name(label) for label in raw or []) if name] + + +def type_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: + return [name for name in label_names(labels) if name.startswith("type:")] + + +def status_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: + return [name for name in label_names(labels) if name.startswith("status:")] + + +def canonical_status_label(status_or_transition: str) -> str: + status = status_or_transition.strip() + if status in STATUS_LABELS: + return status + normalized = status.lower().replace(" ", "-") + try: + return STATUS_TRANSITIONS[normalized] + except KeyError as exc: + raise ValueError( + f"unknown workflow status or transition '{status_or_transition}'" + ) from exc + + +def transition_status_labels( + existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], + status_or_transition: str, +) -> list[str]: + """Replace all active status labels with the requested canonical status.""" + new_status = canonical_status_label(status_or_transition) + kept = [name for name in label_names(existing_labels) if not name.startswith("status:")] + if new_status not in kept: + kept.append(new_status) + return kept + + +def labels_for_new_issue( + issue_type: str | None = None, + initial_status: str | None = None, + extra_labels: Sequence[str] | None = None, + *, + discussion: bool = False, +) -> list[str]: + names = list(extra_labels or []) + if issue_type: + type_name = issue_type if issue_type.startswith("type:") else f"type:{issue_type}" + names.append(type_name) + if discussion: + names.append("type:discussion") + if initial_status: + names.append(canonical_status_label(initial_status)) + + result: list[str] = [] + for name in names: + if name not in result: + result.append(name) + return result + + +def assess_issue_labels( + labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], + *, + discussion: bool = False, + require_type: bool = True, + require_status: bool = True, +) -> dict: + names = label_names(labels) + found_types = type_labels(names) + found_statuses = status_labels(names) + errors: list[str] = [] + warnings: list[str] = [] + + if require_type and not found_types: + errors.append("issue is missing a type:* label") + if require_status and not found_statuses: + errors.append("issue is missing a status:* label") + if discussion and "type:discussion" not in found_types: + errors.append("discussion issue is missing type:discussion") + if len(found_statuses) > 1: + errors.append( + "issue has multiple active status:* labels: " + + ", ".join(found_statuses) + ) + + for name in found_types: + if name not in TYPE_LABELS: + warnings.append(f"unknown type label '{name}'") + for name in found_statuses: + if name not in STATUS_LABELS: + warnings.append(f"unknown status label '{name}'") + + return { + "valid": not errors, + "labels": names, + "type_labels": found_types, + "status_labels": found_statuses, + "errors": errors, + "warnings": warnings, + } diff --git a/manage_labels.py b/manage_labels.py index 0a2f52f..400b319 100755 --- a/manage_labels.py +++ b/manage_labels.py @@ -23,6 +23,7 @@ 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, api_request, repo_api_url +import issue_workflow_labels HOST = "gitea.dadeschools.net" ORG = "Contractor" @@ -35,8 +36,10 @@ LABELS = [ {"name": "epic", "color": "8250df", "description": ""}, {"name": "important", "color": "fbca04", "description": ""}, {"name": "nice-to-have", "color": "0e8a16", "description": ""}, - {"name": "status:in-progress", "color": "fefe2e", - "description": "Issue is being worked on"}, + *[ + {"name": spec.name, "color": spec.color, "description": spec.description} + for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS + ], ] # issue number -> label names to apply (one-off backfill) diff --git a/task_capability_map.py b/task_capability_map.py index 1d40a78..f46f242 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -36,6 +36,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.issue.comment", "role": "author", }, + "create_label": { + "permission": "gitea.issue.comment", + "role": "author", + }, "create_branch": { "permission": "gitea.branch.create", "role": "author", @@ -155,6 +159,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = { "gitea_create_issue_comment": "comment_issue", "gitea_mark_issue": "mark_issue", "gitea_set_issue_labels": "set_issue_labels", + "gitea_create_label": "create_label", "gitea_commit_files": "commit_files", } diff --git a/tests/test_create_issue.py b/tests/test_create_issue.py index b2fdb68..11eb846 100644 --- a/tests/test_create_issue.py +++ b/tests/test_create_issue.py @@ -103,6 +103,37 @@ class TestAPIPayload(unittest.TestCase): self.assertEqual(payload["title"], "My Title") self.assertEqual(payload["body"], "My Body") + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_applies_workflow_labels_by_name(self, _cred): + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and url.endswith("/labels"): + return [ + {"id": 1, "name": "type:feature"}, + {"id": 2, "name": "status:ready"}, + ] + if method == "POST" and url.endswith("/issues"): + return {"number": 7, "html_url": "http://x/7"} + if method == "PUT" and url.endswith("/issues/7/labels"): + return [{"name": "type:feature"}, {"name": "status:ready"}] + return {} + + with patch("create_issue.api_request", side_effect=api_side_effect) as mock_api: + rc = create_issue.main([ + "--title", "My Title", + "--type-label", "feature", + "--status-label", "ready", + ]) + self.assertEqual(rc, 0) + put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT") + self.assertEqual(put_call[0][3], {"labels": [1, 2]}) + + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) + def test_require_workflow_labels_blocks_missing_labels(self, _cred): + with patch("create_issue.api_request") as mock_api: + rc = create_issue.main(["--title", "My Title", "--require-workflow-labels"]) + self.assertEqual(rc, 1) + mock_api.assert_not_called() + @patch("create_issue.get_credentials", return_value=FAKE_CREDS) def test_url_construction(self, _cred): with patch("create_issue.api_request", diff --git a/tests/test_issue_workflow_labels.py b/tests/test_issue_workflow_labels.py new file mode 100644 index 0000000..e7fd143 --- /dev/null +++ b/tests/test_issue_workflow_labels.py @@ -0,0 +1,73 @@ +"""Tests for canonical issue workflow label policy (#513).""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import issue_workflow_labels as labels # noqa: E402 + + +class TestIssueWorkflowLabelValidation(unittest.TestCase): + def test_valid_labeled_issue(self): + result = labels.assess_issue_labels(["type:feature", "status:ready"]) + self.assertTrue(result["valid"]) + self.assertEqual(result["type_labels"], ["type:feature"]) + self.assertEqual(result["status_labels"], ["status:ready"]) + + def test_issue_missing_type_label(self): + result = labels.assess_issue_labels(["status:ready"]) + self.assertFalse(result["valid"]) + self.assertIn("issue is missing a type:* label", result["errors"]) + + def test_issue_missing_status_label(self): + result = labels.assess_issue_labels(["type:bug"]) + self.assertFalse(result["valid"]) + self.assertIn("issue is missing a status:* label", result["errors"]) + + def test_discussion_issue_missing_type_discussion(self): + result = labels.assess_issue_labels( + ["type:feature", "status:triage"], + discussion=True, + ) + self.assertFalse(result["valid"]) + self.assertIn("discussion issue is missing type:discussion", result["errors"]) + + def test_multiple_conflicting_status_labels(self): + result = labels.assess_issue_labels( + ["type:feature", "status:ready", "status:blocked"] + ) + self.assertFalse(result["valid"]) + self.assertTrue( + any("multiple active status:* labels" in err for err in result["errors"]) + ) + + +class TestIssueWorkflowStatusTransitions(unittest.TestCase): + def test_status_transition_removes_old_status_label(self): + result = labels.transition_status_labels( + ["type:feature", "status:ready", "workflow"], + "in-progress", + ) + self.assertEqual(result, ["type:feature", "workflow", "status:in-progress"]) + + def test_pr_open_transition_applies_status_pr_open(self): + result = labels.transition_status_labels( + ["type:feature", "status:in-progress"], + "pr-open", + ) + self.assertEqual(result, ["type:feature", "status:pr-open"]) + + def test_duplicate_transition_applies_status_duplicate(self): + result = labels.transition_status_labels( + ["type:process", "status:triage"], + "duplicate", + ) + self.assertEqual(result, ["type:process", "status:duplicate"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_write_tool_gates.py b/tests/test_issue_write_tool_gates.py index 736b295..f91c514 100644 --- a/tests/test_issue_write_tool_gates.py +++ b/tests/test_issue_write_tool_gates.py @@ -164,13 +164,14 @@ class TestAllowedIssueWritesProceed(IssueWriteGateBase): result = mcp_server.gitea_close_issue(issue_number=9, remote="prgs") self.assertTrue(result.get("success")) + @patch("mcp_server.api_get_all", return_value=[{"id": 10, "name": "status:in-progress"}]) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value="token author-pass") - def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api): + def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api, _labels): def api_side_effect(method, url, auth, payload=None): - if method == "GET" and url.endswith("/labels?limit=100"): - return [{"id": 10, "name": "status:in-progress"}] - if method == "POST" and "/issues/9/labels" in url: + if method == "GET" and "/issues/9" in url: + return {"labels": []} + if method == "PUT" and "/issues/9/labels" in url: return [{"name": "status:in-progress"}] if method == "POST" and "/issues/9/comments" in url: return {"id": 501} @@ -209,4 +210,4 @@ class TestCreateIssueMissingPermissionReport(IssueWriteGateBase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_manage_labels.py b/tests/test_manage_labels.py index cdccf76..ca008a1 100644 --- a/tests/test_manage_labels.py +++ b/tests/test_manage_labels.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, call, patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) import manage_labels # noqa: E402 +import issue_workflow_labels # noqa: E402 FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test") @@ -129,6 +130,11 @@ class TestConstants(unittest.TestCase): names = [l["name"] for l in manage_labels.LABELS] self.assertIn("status:in-progress", names) + def test_canonical_workflow_labels_are_defined(self): + names = {l["name"] for l in manage_labels.LABELS} + for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS: + self.assertIn(spec.name, names) + def test_all_mapped_labels_are_defined(self): defined = {l["name"] for l in manage_labels.LABELS} for issue, names in manage_labels.MAPPING.items(): diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 651b253..fdb1f51 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -85,6 +85,16 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"): _DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease" _NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True} +WORKFLOW_REPO_LABELS = [ + {"id": 1, "name": "type:feature"}, + {"id": 2, "name": "status:in-progress"}, + {"id": 3, "name": "status:ready"}, + {"id": 4, "name": "status:pr-open"}, +] + + +def _issue_with_labels(*names): + return {"labels": [{"name": name} for name in names]} def _reviewer_lease_comment( @@ -293,6 +303,22 @@ class TestCreateIssue(unittest.TestCase): self.assertIn("gitea.prgs.cc", url) self.assertIn("Scaled-Tech-Consulting", url) + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, [])) + @patch("mcp_server.api_request") + @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role): + with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): + result = gitea_create_issue( + title="Test issue", + require_workflow_labels=True, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertIn("issue is missing a type:* label", result["reasons"]) + mock_api.assert_not_called() + # --------------------------------------------------------------------------- # Create PR @@ -303,13 +329,24 @@ class TestCreatePR(unittest.TestCase): "mcp_server.issue_duplicate_context_fetcher", return_value=([], [], {"status": "not_claimed"}), ) + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_creates_pr(self, _auth, mock_api, _role, _dup_fetcher): + def test_creates_pr(self, _auth, mock_api, _role, _labels, _dup_fetcher): worktree = os.path.realpath(os.getcwd()) - mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"} + + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and "/issues/123" in url: + return _issue_with_labels("type:feature", "status:in-progress") + if method == "POST" and url.endswith("/pulls"): + return {"number": 3, "html_url": "https://example.com/pulls/3"} + if method == "PUT" and url.endswith("/issues/123/labels"): + return [{"name": "type:feature"}, {"name": "status:pr-open"}] + return {} + + mock_api.side_effect = api_side_effect with tempfile.TemporaryDirectory() as lock_dir: env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} with patch.dict(os.environ, env, clear=True): @@ -322,22 +359,37 @@ class TestCreatePR(unittest.TestCase): ) self.assertEqual(result["number"], 3) self.assertNotIn("url", result) - payload = mock_api.call_args[0][3] + post_call = next(c for c in mock_api.call_args_list if c[0][0] == "POST") + payload = post_call[0][3] self.assertEqual(payload["head"], "feat/x") self.assertEqual(payload["base"], "main") self.assertIn("Closes #123", payload["title"]) + put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT") + self.assertEqual(put_call[0][3], {"labels": [1, 4]}) + self.assertTrue(result["issue_status_transition"]["performed"]) @patch( "mcp_server.issue_duplicate_context_fetcher", return_value=([], [], {"status": "not_claimed"}), ) + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @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, _role, _dup_fetcher): + def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _labels, _dup_fetcher): worktree = os.path.realpath(os.getcwd()) - mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"} + + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and "/issues/123" in url: + return _issue_with_labels("type:feature", "status:in-progress") + if method == "POST" and url.endswith("/pulls"): + return {"number": 3, "html_url": "https://example.com/pulls/3"} + if method == "PUT" and url.endswith("/issues/123/labels"): + return [{"name": "type:feature"}, {"name": "status:pr-open"}] + return {} + + mock_api.side_effect = api_side_effect with tempfile.TemporaryDirectory() as lock_dir: env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"} with patch.dict(os.environ, env, clear=True): @@ -350,6 +402,38 @@ class TestCreatePR(unittest.TestCase): ) self.assertIn("pulls/3", result["url"]) + @patch( + "mcp_server.issue_duplicate_context_fetcher", + return_value=([], [], {"status": "not_claimed"}), + ) + @patch("mcp_server.api_get_all", return_value=[{"id": 1, "name": "type:feature"}]) + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, [])) + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_pr_blocks_when_pr_open_status_label_missing( + self, _auth, mock_api, _role, _labels, _dup_fetcher + ): + worktree = os.path.realpath(os.getcwd()) + mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress") + with tempfile.TemporaryDirectory() as lock_dir: + env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} + with patch.dict(os.environ, env, clear=True): + _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree) + result = gitea_create_pr( + title="feat: X Closes #123", + head="feat/x", + base="main", + worktree_path=worktree, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertIn("status:pr-open", result["reasons"][0]) + self.assertFalse( + any(c[0][0] == "POST" and c[0][1].endswith("/pulls") + for c in mock_api.call_args_list) + ) + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @@ -474,26 +558,33 @@ class TestViewIssue(unittest.TestCase): # --------------------------------------------------------------------------- class TestMarkIssue(unittest.TestCase): + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_start_adds_label(self, _auth, mock_api): - # labels, add label, post claim heartbeat comment - mock_api.side_effect = [ - [{"id": 10, "name": "status:in-progress"}], - [{"name": "status:in-progress"}], - {"id": 99}, - ] + def test_start_adds_label(self, _auth, mock_api, _labels): + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and "/issues/5" in url: + return _issue_with_labels("type:feature", "status:ready") + if method == "PUT" and url.endswith("/issues/5/labels"): + return [{"name": "type:feature"}, {"name": "status:in-progress"}] + if method == "POST" and url.endswith("/issues/5/comments"): + return {"id": 99} + return {} + + mock_api.side_effect = api_side_effect with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): result = gitea_mark_issue(issue_number=5, action="start") self.assertTrue(result["success"]) self.assertIn("claimed", result["message"]) self.assertTrue(result.get("heartbeat_posted")) + put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT") + self.assertEqual(put_call[0][3], {"labels": [1, 2]}) + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_done_removes_label(self, _auth, mock_api): + def test_done_removes_label(self, _auth, mock_api, _labels): mock_api.side_effect = [ - [{"id": 10, "name": "status:in-progress"}], None, ] with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): @@ -505,10 +596,10 @@ class TestMarkIssue(unittest.TestCase): with self.assertRaises(ValueError): gitea_mark_issue(issue_number=5, action="pause") + @patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_missing_label_raises(self, _auth, mock_api): - mock_api.return_value = [] # no labels exist + def test_missing_label_raises(self, _auth, mock_api, _labels): with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True): with self.assertRaises(RuntimeError): gitea_mark_issue(issue_number=5, action="start") @@ -3750,13 +3841,24 @@ class TestIssueLocking(unittest.TestCase): ) self.assertIn("lock provenance", str(ctx.exception).lower()) + @patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS) @patch("mcp_server.api_request") @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api): + def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api, _labels): scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e") - mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"} + + def api_side_effect(method, url, auth, payload=None): + if method == "GET" and "/issues/249" in url: + return _issue_with_labels("type:feature", "status:in-progress") + if method == "POST" and url.endswith("/pulls"): + return {"number": 250, "html_url": "https://example/pr/250"} + if method == "PUT" and url.endswith("/issues/249/labels"): + return [{"name": "type:feature"}, {"name": "status:pr-open"}] + return {} + + mock_api.side_effect = api_side_effect _bind_test_lock( issue_number=249, branch_name="feat/issue-249-issue-lock-scratch-worktree", @@ -3771,6 +3873,7 @@ class TestIssueLocking(unittest.TestCase): worktree_path=scratch, ) self.assertEqual(res["number"], 250) + self.assertTrue(res["issue_status_transition"]["performed"]) # --------------------------------------------------------------------------- From add87b5708d00c741d3d1ec1b7388963794b3bd3 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 22:30:20 -0400 Subject: [PATCH 5/8] feat: require live PR head re-pin before conflict-fix classification (Closes #522) Treat inventory mergeable/head as advisory. Add classification helper and MCP tool so author sessions re-pin the live PR head before creating a conflict-fix worktree, skip stale inventory false-negatives, and prove classification in final reports. --- README.md | 1 + conflict_fix_classification.py | 266 ++++++++++++++++++ final_report_validator.py | 22 ++ gitea_mcp_server.py | 35 +++ review_proofs.py | 9 + .../workflows/work-issue.md | 31 +- tests/test_conflict_fix_classification.py | 142 ++++++++++ 7 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 conflict_fix_classification.py create mode 100644 tests/test_conflict_fix_classification.py diff --git a/README.md b/README.md index e099658..5ebd8ac 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n | `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) | | `gitea_get_profile` | Read-only: describe the active runtime execution profile (safe metadata only) | | `gitea_check_pr_eligibility` | Read-only: check if the current identity/profile may review/approve/request_changes/merge a PR | +| `gitea_assess_conflict_fix_classification` | Read-only: classify conflict-fix need from a live PR head re-fetch before creating a conflict-fix worktree | | `gitea_submit_pr_review` | Gated review mutation: comment/approve/request_changes, only after identity+profile+eligibility gates pass (no merge, no self-approval) | | `gitea_mark_issue` | Claim/release an issue (start/done) | | `gitea_list_labels` | List all available labels in a repository | diff --git a/conflict_fix_classification.py b/conflict_fix_classification.py new file mode 100644 index 0000000..86815b5 --- /dev/null +++ b/conflict_fix_classification.py @@ -0,0 +1,266 @@ +"""Live PR head re-pin before conflict-fix classification (#522). + +Open PR inventory fields (mergeable, head_sha) can be stale. Author sessions +must re-fetch live PR state and pin the live head before classifying a PR as +conflicted or creating a conflict-fix worktree. +""" + +from __future__ import annotations + +import re +from typing import Any + +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + +_INVENTORY_HEAD_RE = re.compile( + r"(?:inventory head sha|stale inventory head sha)\s*:\s*([0-9a-f]{40})", + re.IGNORECASE, +) +_LIVE_HEAD_RE = re.compile( + r"(?:live head sha|pinned head sha|live pr head sha)\s*:\s*([0-9a-f]{40})", + re.IGNORECASE, +) +_REPINS_TOOL_RE = re.compile( + r"gitea_assess_conflict_fix_classification", + re.IGNORECASE, +) +_CLASSIFICATION_RE = re.compile( + r"conflict[- ]fix classification\s*:\s*(\S+)", + re.IGNORECASE, +) + +CLASSIFICATION_STALE_INVENTORY_SKIP = "stale_inventory_skip" +CLASSIFICATION_CONFLICT_FIX_NEEDED = "conflict_fix_needed" +CLASSIFICATION_LIVE_MERGEABLE = "live_mergeable_skip" +CLASSIFICATION_INCOMPLETE = "incomplete_live_repin" + +_VALID_CLASSIFICATIONS = frozenset({ + CLASSIFICATION_STALE_INVENTORY_SKIP, + CLASSIFICATION_CONFLICT_FIX_NEEDED, + CLASSIFICATION_LIVE_MERGEABLE, + CLASSIFICATION_INCOMPLETE, +}) + + +def _normalize_sha(value: str | None) -> str | None: + text = (value or "").strip().lower() + if not text: + return None + return text if _FULL_SHA.match(text) else None + + +def assess_conflict_fix_classification( + *, + pr_number: int, + inventory_head_sha: str | None = None, + inventory_mergeable: bool | None = None, + live_head_sha: str | None = None, + live_mergeable: bool | None = None, +) -> dict[str, Any]: + """Classify whether conflict-fix work is justified from live PR state. + + Inventory fields are advisory only. Live head + live mergeable are required + before any conflict-fix worktree may be created. + """ + inv_head = _normalize_sha(inventory_head_sha) + live_head = _normalize_sha(live_head_sha) + reasons: list[str] = [] + + if not isinstance(pr_number, int) or pr_number <= 0: + return { + "classification": CLASSIFICATION_INCOMPLETE, + "worktree_allowed": False, + "skip_author_mutation": True, + "inventory_stale_head": False, + "inventory_stale_mergeable": False, + "pinned_head_sha": None, + "inventory_head_sha": inv_head, + "live_head_sha": live_head, + "inventory_mergeable": inventory_mergeable, + "live_mergeable": live_mergeable, + "pr_number": pr_number, + "reasons": ["pr_number must be a positive integer (fail closed)"], + } + + if live_head is None: + reasons.append( + "live PR head SHA missing or not a full 40-char hex SHA; " + "re-fetch the PR before conflict-fix classification (fail closed)" + ) + if live_mergeable is None: + reasons.append( + "live PR mergeable value missing; re-fetch the PR before " + "conflict-fix classification (fail closed)" + ) + + if reasons: + return { + "classification": CLASSIFICATION_INCOMPLETE, + "worktree_allowed": False, + "skip_author_mutation": True, + "inventory_stale_head": bool( + inv_head and live_head and inv_head != live_head + ), + "inventory_stale_mergeable": ( + inventory_mergeable is not None + and live_mergeable is not None + and bool(inventory_mergeable) != bool(live_mergeable) + ), + "pinned_head_sha": live_head, + "inventory_head_sha": inv_head, + "live_head_sha": live_head, + "inventory_mergeable": inventory_mergeable, + "live_mergeable": live_mergeable, + "pr_number": pr_number, + "reasons": reasons, + } + + inventory_stale_head = bool(inv_head and inv_head != live_head) + inventory_stale_mergeable = ( + inventory_mergeable is not None + and bool(inventory_mergeable) != bool(live_mergeable) + ) + + # Live mergeable wins: do not start conflict-fix work. + if live_mergeable is True: + classification = ( + CLASSIFICATION_STALE_INVENTORY_SKIP + if ( + inventory_mergeable is False + or inventory_stale_head + or inventory_stale_mergeable + ) + else CLASSIFICATION_LIVE_MERGEABLE + ) + note = [] + if inventory_stale_head: + note.append( + f"inventory head {inv_head} differs from live head {live_head}; " + "use live head only" + ) + if inventory_mergeable is False: + note.append( + "inventory reported mergeable:false but live PR is mergeable:true; " + "skip author conflict-fix mutation" + ) + return { + "classification": classification, + "worktree_allowed": False, + "skip_author_mutation": True, + "inventory_stale_head": inventory_stale_head, + "inventory_stale_mergeable": inventory_stale_mergeable, + "pinned_head_sha": live_head, + "inventory_head_sha": inv_head, + "live_head_sha": live_head, + "inventory_mergeable": inventory_mergeable, + "live_mergeable": live_mergeable, + "pr_number": pr_number, + "reasons": note, + } + + # live_mergeable is False → conflict-fix may proceed on the pinned live head. + note = [] + if inventory_stale_head: + note.append( + f"inventory head {inv_head} differs from live head {live_head}; " + "pin and use live head only for conflict-fix work" + ) + if inventory_mergeable is True: + note.append( + "inventory reported mergeable:true but live PR is mergeable:false; " + "trust live mergeable and proceed only with live head pin" + ) + note.append( + f"live PR #{pr_number} is mergeable:false at pinned head {live_head}; " + "conflict-fix worktree allowed for that head only" + ) + return { + "classification": CLASSIFICATION_CONFLICT_FIX_NEEDED, + "worktree_allowed": True, + "skip_author_mutation": False, + "inventory_stale_head": inventory_stale_head, + "inventory_stale_mergeable": inventory_stale_mergeable, + "pinned_head_sha": live_head, + "inventory_head_sha": inv_head, + "live_head_sha": live_head, + "inventory_mergeable": inventory_mergeable, + "live_mergeable": live_mergeable, + "pr_number": pr_number, + "reasons": note, + } + + +def assess_conflict_fix_classification_final_report( + report_text: str, + **_kwargs: Any, +) -> dict[str, Any]: + """Require live-head re-pin proof when a report claims conflict-fix work (#522).""" + text = report_text or "" + lower = text.lower() + mentions_conflict_fix = ( + "conflict-fix" in lower + or "conflict fix" in lower + or "conflict_fix" in lower + ) + if not mentions_conflict_fix: + return {"proven": True, "reasons": [], "applicable": False} + + reasons: list[str] = [] + if not _REPINS_TOOL_RE.search(text): + reasons.append( + "conflict-fix report must cite gitea_assess_conflict_fix_classification " + "(live head re-pin tool)" + ) + + live_match = _LIVE_HEAD_RE.search(text) + if not live_match: + reasons.append( + "conflict-fix report must state Live head SHA: <40-char hex> " + "(or Pinned head SHA / Live PR head SHA)" + ) + else: + live_sha = _normalize_sha(live_match.group(1)) + if live_sha is None: + reasons.append("live head SHA is not a full 40-char hex digest") + + inv_match = _INVENTORY_HEAD_RE.search(text) + # Inventory head is optional but recommended when classification is stale skip. + class_match = _CLASSIFICATION_RE.search(text) + if not class_match: + reasons.append( + "conflict-fix report must state Conflict-fix classification: " + f"<{'|'.join(sorted(_VALID_CLASSIFICATIONS))}>" + ) + else: + classification = class_match.group(1).strip().lower().replace(" ", "_") + # allow hyphenated forms + classification = classification.replace("-", "_") + if classification not in _VALID_CLASSIFICATIONS: + reasons.append( + f"unknown conflict-fix classification {class_match.group(1)!r}; " + f"expected one of {sorted(_VALID_CLASSIFICATIONS)}" + ) + + if inv_match and live_match: + inv_sha = _normalize_sha(inv_match.group(1)) + live_sha = _normalize_sha(live_match.group(1)) + if inv_sha and live_sha and inv_sha != live_sha: + # Require explicit note that live head was used. + if "use live head" not in lower and "live head only" not in lower: + reasons.append( + "inventory head differs from live head; report must state that " + "the live head was used exclusively" + ) + + return { + "proven": not reasons, + "reasons": reasons, + "applicable": True, + "inventory_head_sha": _normalize_sha(inv_match.group(1)) if inv_match else None, + "live_head_sha": _normalize_sha(live_match.group(1)) if live_match else None, + "classification": ( + class_match.group(1).strip().lower().replace("-", "_").replace(" ", "_") + if class_match + else None + ), + } diff --git a/final_report_validator.py b/final_report_validator.py index 492c15b..70535e6 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -565,6 +565,27 @@ def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]: ) +def _rule_conflict_fix_classification_proof(report_text: str) -> list[dict[str, str]]: + from conflict_fix_classification import ( + assess_conflict_fix_classification_final_report, + ) + + text = report_text or "" + result = assess_conflict_fix_classification_final_report(text) + if result.get("proven"): + return [] + return _findings_from_reasons( + "author.conflict_fix_classification_proof", + result.get("reasons") or [], + field="Conflict-fix classification", + severity="block", + safe_next_action=( + "call gitea_assess_conflict_fix_classification, state the live head " + "SHA, and state the classification before creating a conflict-fix worktree" + ), + ) + + def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]: from pr_work_lease import assess_conflict_fix_final_report @@ -1271,6 +1292,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { *_SHARED_ISSUE_LOCK_RULES, _rule_shared_issue_acceptance_gate, _rule_reviewer_vague_mutations_none, + _rule_conflict_fix_classification_proof, _rule_conflict_fix_push_proof, _rule_worktree_cleanup_audit_proof, ], diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index ed650b5..8abafc2 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -827,6 +827,7 @@ import reconciliation_workflow # noqa: E402 import audit_reconciliation_mode # noqa: E402 import review_merge_state_machine # noqa: E402 import pr_work_lease # noqa: E402 +import conflict_fix_classification # noqa: E402 import native_mcp_preference # noqa: E402 import worktree_cleanup_audit # noqa: E402 @@ -8209,6 +8210,40 @@ def gitea_acquire_conflict_fix_lease( } +@mcp.tool() +def gitea_assess_conflict_fix_classification( + pr_number: int, + inventory_head_sha: str | None = None, + inventory_mergeable: bool | None = None, + live_head_sha: str | None = None, + live_mergeable: bool | None = None, +) -> dict: + """Read-only: classify conflict-fix need from live PR state (#522). + + Open PR inventory can be stale. Callers must pass a live re-fetched PR head + and mergeability value before creating a conflict-fix worktree. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "performed": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + + result = conflict_fix_classification.assess_conflict_fix_classification( + pr_number=pr_number, + inventory_head_sha=inventory_head_sha, + inventory_mergeable=inventory_mergeable, + live_head_sha=live_head_sha, + live_mergeable=live_mergeable, + ) + result["success"] = True + result["performed"] = False + return result + + @mcp.tool() def gitea_assess_conflict_fix_push( pr_number: int, diff --git a/review_proofs.py b/review_proofs.py index 194ce9e..f7f2a53 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -5540,6 +5540,15 @@ def assess_already_landed_classification_report(report_text, **kwargs): return _assess(report_text, **kwargs) +def assess_conflict_fix_classification_final_report(report_text, **kwargs): + """#522: require live PR head re-pin before conflict-fix classification.""" + from conflict_fix_classification import ( + assess_conflict_fix_classification_final_report as _assess, + ) + + return _assess(report_text, **kwargs) + + def assess_prior_blocker_skip_proof(report_text, **kwargs): """#318: require live blocker proof before skipping earlier open PRs.""" from reviewer_blocker_skip import assess_prior_blocker_skip_proof as _assess diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md index c7f5747..1114033 100644 --- a/skills/llm-project-workflow/workflows/work-issue.md +++ b/skills/llm-project-workflow/workflows/work-issue.md @@ -615,10 +615,39 @@ After push, report: If push fails, stop and produce a recovery handoff. -## 20A. Conflict-fix lease and push gate (#399) +## 20A. Live head re-pin before conflict-fix classification (#522) + +Open PR inventory `mergeable` / `head_sha` fields are **advisory and may be +stale**. Before classifying a PR as conflicted or creating a conflict-fix +worktree: + +1. Re-fetch the live PR (`gitea_view_pr` or equivalent) and record: + * inventory head SHA (if any) + * inventory mergeable (if any) + * **live** head SHA (full 40-char) + * **live** mergeable +2. Call `gitea_assess_conflict_fix_classification` with those values. +3. Act only on the returned classification and **pinned live head**: + * `stale_inventory_skip` / `live_mergeable_skip` → **do not** create a + conflict-fix worktree; do not mutate the PR branch for conflicts. + * `conflict_fix_needed` → conflict-fix worktree allowed for the pinned + live head only. + * `incomplete_live_repin` → stop; re-fetch live state. +4. If inventory head ≠ live head, report both SHAs and use the live head only. +5. If inventory says `mergeable:false` but live says `mergeable:true`, classify + as stale inventory and skip author conflict-fix mutation. + +Conflict-fix final reports that claim conflict-fix work must include: + +* citation of `gitea_assess_conflict_fix_classification` +* `Live head SHA: <40-char hex>` (or Pinned head SHA / Live PR head SHA) +* `Conflict-fix classification: <stale_inventory_skip|conflict_fix_needed|live_mergeable_skip|incomplete_live_repin>` + +## 20B. Conflict-fix lease and push gate (#399) When pushing to an existing PR branch to resolve merge conflicts: +0. Complete §20A live-head re-pin / classification first. 1. Call `gitea_acquire_conflict_fix_lease` before any push. 2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with: * branch head before push diff --git a/tests/test_conflict_fix_classification.py b/tests/test_conflict_fix_classification.py new file mode 100644 index 0000000..0c1ea82 --- /dev/null +++ b/tests/test_conflict_fix_classification.py @@ -0,0 +1,142 @@ +"""Tests for live PR head re-pin before conflict-fix classification (#522).""" + +from __future__ import annotations + +import unittest + +from conflict_fix_classification import ( + CLASSIFICATION_CONFLICT_FIX_NEEDED, + CLASSIFICATION_INCOMPLETE, + CLASSIFICATION_LIVE_MERGEABLE, + CLASSIFICATION_STALE_INVENTORY_SKIP, + assess_conflict_fix_classification, + assess_conflict_fix_classification_final_report, +) + + +def _sha(prefix: str) -> str: + return (prefix + "0" * 40)[:40] + + +class TestConflictFixClassification(unittest.TestCase): + def test_stale_inventory_mergeable_skip(self): + """PR #508 style: inventory mergeable:false/stale head, live mergeable:true.""" + result = assess_conflict_fix_classification( + pr_number=508, + inventory_head_sha=_sha("dad1dc8"), + inventory_mergeable=False, + live_head_sha=_sha("3f3d6cb"), + live_mergeable=True, + ) + self.assertEqual(result["classification"], CLASSIFICATION_STALE_INVENTORY_SKIP) + self.assertTrue(result["skip_author_mutation"]) + self.assertFalse(result["worktree_allowed"]) + self.assertTrue(result["inventory_stale_head"]) + self.assertTrue(result["inventory_stale_mergeable"]) + self.assertEqual(result["pinned_head_sha"], _sha("3f3d6cb")) + + def test_stale_inventory_head_only_live_mergeable_true(self): + """PR #493 style: head moved; live still mergeable.""" + result = assess_conflict_fix_classification( + pr_number=493, + inventory_head_sha=_sha("685e627"), + inventory_mergeable=True, + live_head_sha=_sha("4561d7a"), + live_mergeable=True, + ) + self.assertEqual(result["classification"], CLASSIFICATION_STALE_INVENTORY_SKIP) + self.assertTrue(result["skip_author_mutation"]) + self.assertFalse(result["worktree_allowed"]) + self.assertTrue(result["inventory_stale_head"]) + self.assertEqual(result["pinned_head_sha"], _sha("4561d7a")) + + def test_live_conflict_allows_worktree_on_pinned_head(self): + result = assess_conflict_fix_classification( + pr_number=99, + inventory_head_sha=_sha("aaaaaaa"), + inventory_mergeable=False, + live_head_sha=_sha("bbbbbbb"), + live_mergeable=False, + ) + self.assertEqual(result["classification"], CLASSIFICATION_CONFLICT_FIX_NEEDED) + self.assertTrue(result["worktree_allowed"]) + self.assertFalse(result["skip_author_mutation"]) + self.assertTrue(result["inventory_stale_head"]) + self.assertEqual(result["pinned_head_sha"], _sha("bbbbbbb")) + + def test_matching_inventory_live_mergeable_skip(self): + head = _sha("cccccccc") + result = assess_conflict_fix_classification( + pr_number=10, + inventory_head_sha=head, + inventory_mergeable=True, + live_head_sha=head, + live_mergeable=True, + ) + self.assertEqual(result["classification"], CLASSIFICATION_LIVE_MERGEABLE) + self.assertFalse(result["worktree_allowed"]) + self.assertTrue(result["skip_author_mutation"]) + self.assertFalse(result["inventory_stale_head"]) + + def test_missing_live_head_incomplete(self): + result = assess_conflict_fix_classification( + pr_number=1, + inventory_head_sha=_sha("ddddddd"), + inventory_mergeable=False, + live_head_sha=None, + live_mergeable=False, + ) + self.assertEqual(result["classification"], CLASSIFICATION_INCOMPLETE) + self.assertFalse(result["worktree_allowed"]) + self.assertTrue(result["skip_author_mutation"]) + self.assertTrue(any("live PR head" in r for r in result["reasons"])) + + def test_short_sha_rejected(self): + result = assess_conflict_fix_classification( + pr_number=1, + inventory_head_sha="abc1234", + inventory_mergeable=False, + live_head_sha="def5678", + live_mergeable=False, + ) + self.assertEqual(result["classification"], CLASSIFICATION_INCOMPLETE) + self.assertFalse(result["worktree_allowed"]) + + +class TestConflictFixClassificationFinalReport(unittest.TestCase): + def test_non_conflict_report_not_applicable(self): + result = assess_conflict_fix_classification_final_report( + "Implemented feature without merge issues." + ) + self.assertTrue(result["proven"]) + self.assertFalse(result["applicable"]) + + def test_conflict_report_requires_tool_live_head_classification(self): + result = assess_conflict_fix_classification_final_report( + "Did conflict-fix work on PR #99." + ) + self.assertFalse(result["proven"]) + self.assertTrue(result["applicable"]) + joined = " ".join(result["reasons"]) + self.assertIn("gitea_assess_conflict_fix_classification", joined) + self.assertIn("Live head SHA", joined) + self.assertIn("classification", joined.lower()) + + def test_good_conflict_report_passes(self): + live = _sha("3f3d6cb") + inv = _sha("dad1dc8") + text = f""" +Conflict-fix classification: stale_inventory_skip +Called gitea_assess_conflict_fix_classification before worktree creation. +Inventory head SHA: {inv} +Live head SHA: {live} +Use live head only; skip author mutation. +""" + result = assess_conflict_fix_classification_final_report(text) + self.assertTrue(result["proven"], msg=result.get("reasons")) + self.assertEqual(result["live_head_sha"], live) + self.assertEqual(result["inventory_head_sha"], inv) + + +if __name__ == "__main__": + unittest.main() From 84d921a52fe9d31b399f4347e7e4518815105f60 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 03:13:42 -0400 Subject: [PATCH 6/8] feat(#496): fail-closed canonical comment validation before Gitea posts Add canonical_comment_validator and wire it into issue comments, PR review bodies, reconcile post_comment, and structured comment helpers. Workflow-changing comments without complete next-action state are rejected before any API call. Includes unit/MCP integration tests, runbook docs, and a final-report rule blocking false "comment posted" claims when validation failed. --- canonical_comment_validator.py | 408 ++++++++++++++++++++++ docs/llm-workflow-runbooks.md | 49 +++ final_report_validator.py | 65 ++++ gitea_mcp_server.py | 61 ++++ tests/test_canonical_comment_validator.py | 217 ++++++++++++ tests/test_final_report_validator.py | 25 ++ tests/test_mcp_server.py | 30 ++ 7 files changed, 855 insertions(+) create mode 100644 canonical_comment_validator.py create mode 100644 tests/test_canonical_comment_validator.py diff --git a/canonical_comment_validator.py b/canonical_comment_validator.py new file mode 100644 index 0000000..a2d05a0 --- /dev/null +++ b/canonical_comment_validator.py @@ -0,0 +1,408 @@ +"""Fail-closed validation for workflow-changing Gitea comments (#496).""" + +from __future__ import annotations + +import re +from typing import Any + +VALID_ROLES = frozenset({ + "controller", + "author", + "reviewer", + "merger", + "reconciler", + "user", +}) + +_BASE_REQUIRED = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT", "WHY") + +_ISSUE_REQUIRED = _BASE_REQUIRED + ("BLOCKERS", "VALIDATION") +_PR_REQUIRED = _BASE_REQUIRED + ( + "ISSUE", + "HEAD_SHA", + "REVIEW_STATUS", + "MERGE_READY", + "BLOCKERS", + "VALIDATION", +) +_SUPERSESSION_REQUIRED = _BASE_REQUIRED + ( + "CANONICAL_ITEM", + "SUPERSEDED_ITEM", + "CLOSE_OR_KEEP_OPEN", +) + +_MACHINE_MARKERS = ( + "<!-- mcp-review-lease:v1 -->", + "<!-- mcp-conflict-fix-lease:v1 -->", + "<!-- gitea-issue-claim-heartbeat:v1 -->", +) + +_WORKFLOW_TRIGGERS = re.compile( + r"\b(?:" + r"blocked|unblocked|ready(?:\s+for\s+(?:review|merge|author))?|" + r"ready-to-merge|approved|approve|request\s+changes|changes\s+requested|" + r"superseded|duplicate|canonical|next\s+action|next\s+actor|who\s+is\s+next|" + r"author\s+should|reviewer\s+should|merger\s+should|reconciler\s+should|" + r"controller\s+should|issue\s+complete|pr\s+open|pr\s+merged|close\s+this|" + r"do\s+not\s+merge|needs?\s+rebase|stale\s+approval|contaminated\s+review|" + r"merge\s+ready|ready\s+for\s+merge" + r")\b", + re.IGNORECASE, +) + +_CANONICAL_HEADINGS = ( + "## Canonical Issue State", + "## Canonical PR State", + "## Canonical Discussion Summary", +) + +_FIELD_RE = re.compile( + r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", + re.MULTILINE, +) + +_VAGUE_NEXT_ACTIONS = frozenset({ + "continue", + "handle this", + "fix it", + "follow up", + "follow-up", + "see above", + "see review", + "tbd", + "todo", + "as needed", + "proceed", + "next steps", + "will check", + "investigate", +}) + +_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE) +_SHORT_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE) +_PR_REF_RE = re.compile(r"(?:PR\s*#|pull\s*#)\d+|\b#\d{2,}\b", re.IGNORECASE) +_APPROVAL_PROOF_RE = re.compile( + r"\b(?:approved|approval_at_current_head|APPROVE)\b", + re.IGNORECASE, +) +_UNBLOCK_RE = re.compile( + r"\b(?:unblock|until|after|once|when|requires?|must)\b", + re.IGNORECASE, +) + + +def _parse_fields(body: str) -> dict[str, str]: + """Parse KEY: value fields, including values continued on following lines.""" + fields: dict[str, str] = {} + current_key: str | None = None + current_lines: list[str] = [] + + def _flush() -> None: + nonlocal current_key, current_lines + if current_key is not None: + fields[current_key] = "\n".join(current_lines).strip() + current_key = None + current_lines = [] + + for line in (body or "").splitlines(): + if line.startswith("## "): + _flush() + continue + match = re.match(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", line) + if match: + _flush() + current_key = match.group(1).strip().upper() + rest = match.group(2) + current_lines = [rest] if rest else [] + elif current_key is not None: + current_lines.append(line) + _flush() + return fields + + +def _is_machine_generated(body: str) -> bool: + text = body or "" + return any(marker in text for marker in _MACHINE_MARKERS) + + +def _has_canonical_heading(body: str) -> bool: + return any(h in (body or "") for h in _CANONICAL_HEADINGS) + + +def is_workflow_changing_comment(body: str) -> bool: + """True when comment text implies a workflow/state transition.""" + text = (body or "").strip() + if not text: + return False + if _is_machine_generated(text): + return False + if _has_canonical_heading(text): + return True + if _WORKFLOW_TRIGGERS.search(text): + return True + fields = _parse_fields(text) + if "STATE" in fields or "WHO_IS_NEXT" in fields: + return True + return False + + +def infer_comment_context(body: str, *, explicit: str | None = None) -> str: + if explicit: + return explicit + text = body or "" + if "## Canonical Discussion Summary" in text: + return "discussion_summary" + if "## Canonical PR State" in text: + return "pr_comment" + if "## Canonical Issue State" in text: + return "issue_comment" + fields = _parse_fields(text) + if fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_ITEM"): + return "supersession" + if any(k in fields for k in ("HEAD_SHA", "REVIEW_STATUS", "MERGE_READY", "ISSUE")): + return "pr_comment" + return "issue_comment" + + +def _required_fields_for_context(context: str) -> tuple[str, ...]: + if context in ("pr_comment", "pr_review"): + return _PR_REQUIRED + if context == "supersession": + return _SUPERSESSION_REQUIRED + if context == "discussion_summary": + return _BASE_REQUIRED + ("DECISION", "SUBSTANTIVE_COMMENTS") + return _ISSUE_REQUIRED + + +def _is_vague_next_action(value: str) -> bool: + normalized = re.sub(r"\s+", " ", (value or "").strip().lower()) + normalized = normalized.rstrip(".") + if not normalized: + return True + if normalized in _VAGUE_NEXT_ACTIONS: + return True + if len(normalized) < 12: + return True + return False + + +def _next_prompt_ok(value: str) -> bool: + text = (value or "").strip() + if len(text) < 40: + return False + if text.lower() in {"n/a", "none", "tbd", "todo"}: + return False + return True + + +def _state_value(fields: dict[str, str]) -> str: + return (fields.get("STATE") or "").strip().lower() + + +def _suggested_template(context: str) -> str: + if context == "pr_comment" or context == "pr_review": + return ( + "## Canonical PR State\n\n" + "STATE:\n" + "WHO_IS_NEXT:\n" + "NEXT_ACTION:\n" + "NEXT_PROMPT:\n" + "```text\n<paste-ready prompt>\n```\n" + "WHAT_HAPPENED:\n" + "WHY:\n" + "ISSUE:\n" + "HEAD_SHA:\n" + "REVIEW_STATUS:\n" + "MERGE_READY:\n" + "BLOCKERS:\n" + "VALIDATION:\n" + "LAST_UPDATED_BY:\n" + ) + if context == "supersession": + return ( + "## Canonical PR State\n\n" + "STATE:\nsuperseded\n" + "WHO_IS_NEXT:\nreconciler\n" + "NEXT_ACTION:\n" + "NEXT_PROMPT:\n" + "WHY:\n" + "CANONICAL_ITEM:\n" + "SUPERSEDED_ITEM:\n" + "CLOSE_OR_KEEP_OPEN:\n" + ) + if context == "discussion_summary": + return ( + "## Canonical Discussion Summary\n\n" + "STATE:\n" + "WHO_IS_NEXT:\n" + "DECISION:\n" + "WHY:\n" + "SUBSTANTIVE_COMMENTS:\n" + "NEXT_ACTION:\n" + "NEXT_PROMPT:\n" + ) + return ( + "## Canonical Issue State\n\n" + "STATE:\n" + "WHO_IS_NEXT:\n" + "NEXT_ACTION:\n" + "NEXT_PROMPT:\n" + "```text\n<paste-ready prompt>\n```\n" + "WHAT_HAPPENED:\n" + "WHY:\n" + "RELATED_PRS:\n" + "BLOCKERS:\n" + "VALIDATION:\n" + "LAST_UPDATED_BY:\n" + ) + + +def _build_correction_message( + *, + missing_fields: list[str], + vague_fields: list[str], + extra_reasons: list[str], + context: str, +) -> str: + parts = [ + "Canonical comment validation failed (fail closed before posting).", + ] + if missing_fields: + parts.append("Missing fields: " + ", ".join(missing_fields) + ".") + if vague_fields: + parts.append("Vague or invalid fields: " + ", ".join(vague_fields) + ".") + parts.extend(extra_reasons) + parts.append("Fill the suggested template and retry.") + parts.append("Suggested template:\n" + _suggested_template(context)) + return "\n".join(parts) + + +def assess_canonical_comment( + body: str, + *, + context: str | None = None, + force_workflow: bool = False, +) -> dict[str, Any]: + """Validate outgoing comment text before a Gitea mutation.""" + text = (body or "").strip() + ctx = infer_comment_context(text, explicit=context) + + if not text: + return { + "allowed": True, + "is_workflow_comment": False, + "context": ctx, + "missing_fields": [], + "vague_fields": [], + "correction_message": "", + "suggested_template": "", + } + + if _is_machine_generated(text): + return { + "allowed": True, + "is_workflow_comment": False, + "context": ctx, + "missing_fields": [], + "vague_fields": [], + "correction_message": "", + "suggested_template": "", + } + + workflow = force_workflow or is_workflow_changing_comment(text) + if not workflow: + return { + "allowed": True, + "is_workflow_comment": False, + "context": ctx, + "missing_fields": [], + "vague_fields": [], + "correction_message": "", + "suggested_template": "", + } + + fields = _parse_fields(text) + required = _required_fields_for_context(ctx) + missing = [name for name in required if not (fields.get(name) or "").strip()] + + vague: list[str] = [] + extra: list[str] = [] + + who = (fields.get("WHO_IS_NEXT") or "").strip().lower() + if who and who not in VALID_ROLES: + vague.append("WHO_IS_NEXT") + extra.append( + f"WHO_IS_NEXT must be one of: {', '.join(sorted(VALID_ROLES))}." + ) + + next_action = fields.get("NEXT_ACTION") or "" + if next_action and _is_vague_next_action(next_action): + vague.append("NEXT_ACTION") + + next_prompt = fields.get("NEXT_PROMPT") or "" + if not _next_prompt_ok(next_prompt): + if "NEXT_PROMPT" not in missing: + vague.append("NEXT_PROMPT") + + state = _state_value(fields) + blockers = (fields.get("BLOCKERS") or "").strip() + if "blocked" in state: + if not blockers or blockers.lower() in {"none", "n/a"}: + missing.append("BLOCKERS (unblock condition)") + elif not _UNBLOCK_RE.search(blockers): + vague.append("BLOCKERS") + extra.append("BLOCKED state requires an explicit unblock condition in BLOCKERS.") + + if "superseded" in state: + canon = (fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY") or "").strip() + superseded = (fields.get("SUPERSEDED_ITEM") or fields.get("SUPERSEDES") or "").strip() + if not canon and "CANONICAL_ITEM" not in missing: + missing.append("CANONICAL_ITEM") + if not superseded and "SUPERSEDED_ITEM" not in missing: + missing.append("SUPERSEDED_ITEM") + + if "ready-to-merge" in state or "ready to merge" in state: + head_sha = fields.get("HEAD_SHA") or "" + merge_ready = fields.get("MERGE_READY") or "" + review_status = fields.get("REVIEW_STATUS") or "" + validation = fields.get("VALIDATION") or "" + proof_blob = " ".join((head_sha, merge_ready, review_status, validation)) + has_sha = bool(_FULL_SHA_RE.search(proof_blob) or _SHORT_SHA_RE.search(proof_blob)) + has_approval = bool(_APPROVAL_PROOF_RE.search(proof_blob)) + if not has_sha or not has_approval: + extra.append( + "ready-to-merge STATE requires approval proof and HEAD_SHA in " + "REVIEW_STATUS, MERGE_READY, HEAD_SHA, or VALIDATION." + ) + + if ctx in ("pr_comment", "pr_review"): + head_sha = (fields.get("HEAD_SHA") or "").strip() + if not head_sha or not _SHORT_SHA_RE.search(head_sha): + if "HEAD_SHA" not in missing: + missing.append("HEAD_SHA") + + if ctx == "issue_comment" and _PR_REF_RE.search(text): + related = (fields.get("RELATED_PRS") or "").strip() + if not related or related.lower() in {"none", "n/a", "-"}: + missing.append("RELATED_PRS") + + allowed = not missing and not vague and not extra + correction = "" + if not allowed: + correction = _build_correction_message( + missing_fields=missing, + vague_fields=vague, + extra_reasons=extra, + context=ctx, + ) + + return { + "allowed": allowed, + "is_workflow_comment": True, + "context": ctx, + "missing_fields": missing, + "vague_fields": vague, + "extra_reasons": extra, + "correction_message": correction, + "suggested_template": _suggested_template(ctx) if not allowed else "", + } \ No newline at end of file diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index d0e472d..912b460 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -818,6 +818,55 @@ touched release state names the exact tag/commit and why. Design debates belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment on the issue, create no branches/PRs, and end the comment with this handoff. +## Canonical comment validation (#496) + +Workflow-changing issue/PR/review comments must carry durable next-action +state. Casual discussion is still allowed. + +The MCP server runs `canonical_comment_validator.assess_canonical_comment` +**before** posting through: + +- `gitea_create_issue_comment` +- `gitea_submit_pr_review` / `gitea_dry_run_pr_review` (non-empty review bodies) +- `gitea_reconcile_already_landed_pr` when `post_comment=True` +- internal structured comment helpers (machine lease/heartbeat markers stay exempt) + +Detection examples: + +- **Allowed:** `Thanks, I will check this.` +- **Rejected:** `Blocked, author should fix.` (workflow trigger without canonical fields) + +When validation fails, the tool returns `canonical_comment_validation` with +`allowed: false`, `missing_fields`, `vague_fields`, `correction_message`, and +`suggested_template`. **No Gitea API call is made.** + +Minimum workflow comment fields: + +```text +STATE: +WHO_IS_NEXT: +NEXT_ACTION: +NEXT_PROMPT: +WHY: +``` + +`WHO_IS_NEXT` must be one of: `controller`, `author`, `reviewer`, `merger`, +`reconciler`, `user`. + +Issue comments also require `RELATED_PRS`, `BLOCKERS`, and `VALIDATION` when +they mention PR work. PR comments/reviews also require `ISSUE`, `HEAD_SHA`, +`REVIEW_STATUS`, `MERGE_READY`, `BLOCKERS`, and `VALIDATION`. + +Special states: + +- `STATE: blocked` — `BLOCKERS` must name an explicit unblock condition. +- `STATE: superseded` — requires `CANONICAL_ITEM` and `SUPERSEDED_ITEM`. +- `STATE: ready-to-merge` — requires approval proof and head SHA in + `HEAD_SHA`, `REVIEW_STATUS`, `MERGE_READY`, or `VALIDATION`. + +Final reports must not claim a comment was posted when +`canonical_comment_validation.allowed` is false (#496 AC14). + ## Fail-closed behavior Before any mutating action the workflow verifies identity, active profile, diff --git a/final_report_validator.py b/final_report_validator.py index a807ff9..c5198fe 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -194,6 +194,23 @@ _PAGINATION_PROOF_RE = re.compile( re.IGNORECASE, ) _GIT_FETCH_RE = re.compile(r"\bgit\s+fetch\b", re.IGNORECASE) +_CANONICAL_VALIDATION_REJECTED_RE = re.compile( + r"canonical comment validation failed|" + r"canonical_comment_validation|" + r'"allowed"\s*:\s*false', + re.IGNORECASE, +) +_COMMENT_POSTED_CLAIM_RE = re.compile( + r"(?:issue comments posted\s*:\s+(?!none\b)\S|" + r"pr comments posted\s*:\s+(?!none\b)\S|" + r"comment_id\s*[:=]\s*\d+|" + r"gitea comment (?:was )?posted|" + r"posted (?:issue|pr|canonical) (?:state )?comment|" + r"comment posted successfully|" + r"mcp/gitea mutations\s*:\s*[^;\n]*comment posted|" + r"reconciliation mutations\s*:\s*[^;\n]*comment posted)", + re.IGNORECASE, +) _READONLY_DIAG_RE = re.compile( r"read[- ]only diagnostics\s*:\s*(.+)$", re.IGNORECASE | re.MULTILINE, @@ -348,6 +365,43 @@ def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]: ) +def _rule_shared_canonical_comment_post_claim( + report_text: str, + *, + action_log: list[dict] | None = None, +) -> list[dict[str, str]]: + """#496: reports must not claim comment posted when validator rejected.""" + text = report_text or "" + rejected_in_report = bool(_CANONICAL_VALIDATION_REJECTED_RE.search(text)) + rejected_in_log = False + if action_log: + for entry in action_log: + validation = entry.get("canonical_comment_validation") or {} + if validation.get("allowed") is False: + rejected_in_log = True + break + result = entry.get("result") or {} + nested = result.get("canonical_comment_validation") or {} + if nested.get("allowed") is False: + rejected_in_log = True + break + if not (rejected_in_report or rejected_in_log): + return [] + if not _COMMENT_POSTED_CLAIM_RE.search(text): + return [] + return [ + validator_finding( + "shared.canonical_comment_post_claim", + "block", + "MCP/Gitea mutations", + "report claims a Gitea comment was posted while canonical comment " + "validation rejected the workflow comment", + "do not claim comment posted when validator fail-closed; repair " + "the canonical comment body and retry posting", + ) + ] + + def _rule_reviewer_legacy_workspace_mutations( report_text: str, *, @@ -1279,10 +1333,15 @@ _SHARED_CLEANUP_PROOF_RULES = ( _rule_shared_mcp_native_cleanup_proof, ) +_SHARED_CANONICAL_COMMENT_RULES = ( + _rule_shared_canonical_comment_post_claim, +) + _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "review_pr": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_legacy_workspace_mutations, _rule_reviewer_vague_mutations_none, @@ -1311,6 +1370,7 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "reconcile_already_landed": [ _rule_reconcile_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, *_SHARED_CLEANUP_PROOF_RULES, _rule_reconcile_stale_author_fields, @@ -1326,12 +1386,14 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "author_issue": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reviewer_vague_mutations_none, ], "work_issue": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_shared_issue_acceptance_gate, _rule_reviewer_vague_mutations_none, @@ -1341,17 +1403,20 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = { "issue_filing": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, ], "inventory": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, _rule_reconcile_pagination_proof, ], "issue_selection": [ _rule_shared_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_CANONICAL_COMMENT_RULES, *_SHARED_ISSUE_LOCK_RULES, ], } diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index ed650b5..83c134b 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -829,6 +829,7 @@ import review_merge_state_machine # noqa: E402 import pr_work_lease # noqa: E402 import native_mcp_preference # noqa: E402 import worktree_cleanup_audit # noqa: E402 +import canonical_comment_validator as ccv # noqa: E402 # Keyed issue-lock storage (#443): per remote/org/repo/issue files under @@ -3268,6 +3269,13 @@ def _evaluate_pr_review_submission( result["pr_work_lease"] = lease_block return result + if (body or "").strip(): + gate = _canonical_comment_gate(body, context="pr_review") + if gate["blocked"]: + reasons.extend(gate["reasons"]) + result["canonical_comment_validation"] = gate["canonical_comment_validation"] + return result + result["would_perform"] = True if not live: reasons.append( @@ -5207,6 +5215,12 @@ def gitea_reconcile_already_landed_pr( "gitea.pr.comment" ) return result + gate = _canonical_comment_gate(comment_body) + if gate["blocked"]: + result["success"] = False + result["reasons"].extend(gate["reasons"]) + result["canonical_comment_validation"] = gate["canonical_comment_validation"] + return result comment_url = f"{base}/issues/{pr_number}/comments" with _audited( "comment_pr", @@ -6444,6 +6458,15 @@ def gitea_create_issue_comment( blocked["permission_report"] = _permission_block_report( "gitea.issue.comment") return blocked + canon_gate = _canonical_comment_gate(body, context="issue_comment") + if canon_gate["blocked"]: + return { + "success": False, + "performed": False, + "issue_number": issue_number, + "reasons": canon_gate["reasons"], + "canonical_comment_validation": canon_gate["canonical_comment_validation"], + } h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments" @@ -7973,6 +7996,34 @@ def gitea_audit_config() -> dict: return report +def _canonical_comment_gate( + body: str, + *, + context: str | None = None, + force_workflow: bool = False, +) -> dict: + """Fail-closed canonical state validation before comment/review POST (#496).""" + assessment = ccv.assess_canonical_comment( + body, + context=context, + force_workflow=force_workflow, + ) + if assessment.get("allowed"): + return { + "blocked": False, + "canonical_comment_validation": assessment, + "reasons": [], + } + correction = (assessment.get("correction_message") or "").strip() + return { + "blocked": True, + "canonical_comment_validation": assessment, + "reasons": [ + correction or "canonical comment validation failed (fail closed before posting)" + ], + } + + def _post_structured_issue_comment( *, issue_number: int, @@ -7982,8 +8033,18 @@ def _post_structured_issue_comment( org: str | None, repo: str | None, audit_op: str = "create_issue_comment", + comment_context: str | None = None, ) -> dict: """Post an issue-thread comment after permission gates already passed.""" + gate = _canonical_comment_gate(body, context=comment_context) + if gate["blocked"]: + return { + "success": False, + "performed": False, + "issue_number": issue_number, + "reasons": gate["reasons"], + "canonical_comment_validation": gate["canonical_comment_validation"], + } h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments" diff --git a/tests/test_canonical_comment_validator.py b/tests/test_canonical_comment_validator.py new file mode 100644 index 0000000..47e75d8 --- /dev/null +++ b/tests/test_canonical_comment_validator.py @@ -0,0 +1,217 @@ +"""Unit tests for canonical_comment_validator (#496).""" + +import unittest + +import canonical_comment_validator as ccv + +FULL_SHA = "a" * 40 + +_VALID_ISSUE = f"""## Canonical Issue State + +STATE: ready-for-author +WHO_IS_NEXT: author +NEXT_ACTION: Implement canonical comment validator and wire MCP gates +NEXT_PROMPT: +```text +You are the author LLM for issue #496. Add canonical_comment_validator.py +and wire fail-closed gates into gitea_create_issue_comment. +``` +WHAT_HAPPENED: Issue filed for MCP validation wall +WHY: Workflow comments must carry durable next-action state +RELATED_PRS: none +BLOCKERS: none +VALIDATION: unit tests not yet run +LAST_UPDATED_BY: prgs-author +""" + +_VALID_PR = f"""## Canonical PR State + +STATE: ready-for-review +WHO_IS_NEXT: reviewer +NEXT_ACTION: Run focused pytest and review the validator wiring diff +NEXT_PROMPT: +```text +Review PR for issue #496. Confirm casual comments still post and workflow +comments without canonical fields are rejected before the Gitea API call. +``` +WHAT_HAPPENED: Author opened PR with validator module +WHY: Fail-closed enforcement belongs at the MCP boundary +ISSUE: #496 +HEAD_SHA: {FULL_SHA} +REVIEW_STATUS: pending +MERGE_READY: false +BLOCKERS: none +VALIDATION: not yet run +LAST_UPDATED_BY: prgs-author +""" + +_VALID_SUPERSEDED = """## Canonical PR State + +STATE: superseded +WHO_IS_NEXT: reconciler +NEXT_ACTION: Close superseded issue after canonical issue #495 lands +NEXT_PROMPT: +```text +Close issue #494 as superseded by #495 once templates and docs are merged. +``` +WHY: Duplicate tracking issue replaced by durable canonical template work +CANONICAL_ITEM: issue #495 +SUPERSEDED_ITEM: issue #494 +CLOSE_OR_KEEP_OPEN: close superseded issue #494 after #495 merges +""" + +_VALID_BLOCKED = """## Canonical Issue State + +STATE: blocked +WHO_IS_NEXT: author +NEXT_ACTION: Fix failing validator unit tests before requesting re-review +NEXT_PROMPT: +```text +Run pytest tests/test_canonical_comment_validator.py and repair failures. +``` +WHAT_HAPPENED: Validator tests failed in CI +WHY: Blocked until test suite is green +RELATED_PRS: PR #500 +BLOCKERS: pytest failures; unblock after all validator tests pass +VALIDATION: pytest failed +LAST_UPDATED_BY: prgs-author +""" + +_VALID_READY_TO_MERGE = f"""## Canonical PR State + +STATE: ready-to-merge +WHO_IS_NEXT: merger +NEXT_ACTION: Merge PR after confirming approval_at_current_head +NEXT_PROMPT: +```text +Merge PR #500 for issue #496 after live mergeable check passes. +``` +WHAT_HAPPENED: Reviewer approved at current head +WHY: All gates passed and head SHA is current +ISSUE: #496 +HEAD_SHA: {FULL_SHA} +REVIEW_STATUS: approved / approval_at_current_head +MERGE_READY: true +BLOCKERS: none +VALIDATION: pytest passed; reviewer approved at head {FULL_SHA} +LAST_UPDATED_BY: prgs-reviewer +""" + + +class TestCanonicalCommentDetection(unittest.TestCase): + def test_casual_comment_not_workflow(self): + self.assertFalse(ccv.is_workflow_changing_comment("Thanks, I will check this.")) + + def test_workflow_trigger_detected(self): + self.assertTrue(ccv.is_workflow_changing_comment("Blocked, author should fix.")) + + def test_machine_marker_exempt(self): + body = "<!-- mcp-review-lease:v1 -->\nphase: claimed" + self.assertFalse(ccv.is_workflow_changing_comment(body)) + + +class TestCanonicalCommentValidation(unittest.TestCase): + def test_casual_comment_allowed(self): + result = ccv.assess_canonical_comment("Thanks, I will check this.") + self.assertTrue(result["allowed"]) + self.assertFalse(result["is_workflow_comment"]) + + def test_workflow_comment_missing_fields_rejected(self): + result = ccv.assess_canonical_comment("Blocked, author should fix.") + self.assertFalse(result["allowed"]) + self.assertTrue(result["is_workflow_comment"]) + self.assertIn("WHO_IS_NEXT", result["missing_fields"]) + self.assertIn("correction_message", result) + self.assertIn("Suggested template", result["correction_message"]) + + def test_missing_next_prompt_rejected(self): + body = """STATE: blocked +WHO_IS_NEXT: author +NEXT_ACTION: Repair the failing validator unit tests in CI +WHY: Tests must pass before merge +BLOCKERS: pytest failed; unblock after tests pass +VALIDATION: failed +""" + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("NEXT_PROMPT", result["missing_fields"] + result["vague_fields"]) + + def test_vague_next_action_rejected(self): + body = _VALID_ISSUE.replace( + "Implement canonical comment validator and wire MCP gates", + "continue", + ) + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("NEXT_ACTION", result["vague_fields"]) + + def test_invalid_who_is_next_rejected(self): + body = _VALID_ISSUE.replace("WHO_IS_NEXT: author", "WHO_IS_NEXT: llm") + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("WHO_IS_NEXT", result["vague_fields"]) + + def test_valid_issue_comment_allowed(self): + result = ccv.assess_canonical_comment(_VALID_ISSUE) + self.assertTrue(result["allowed"]) + + def test_issue_comment_mentioning_pr_requires_related_prs(self): + body = _VALID_ISSUE.replace("RELATED_PRS: none", "RELATED_PRS:") + body = body.replace("Issue filed", "Issue filed; see PR #500") + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("RELATED_PRS", result["missing_fields"]) + + def test_pr_review_requires_head_sha(self): + body = _VALID_PR.replace(f"HEAD_SHA: {FULL_SHA}", "HEAD_SHA:") + result = ccv.assess_canonical_comment(body, context="pr_review") + self.assertFalse(result["allowed"]) + self.assertIn("HEAD_SHA", result["missing_fields"]) + + def test_ready_to_merge_requires_approval_and_sha_proof(self): + body = _VALID_READY_TO_MERGE.replace("approval_at_current_head", "pending") + body = body.replace(f"HEAD_SHA: {FULL_SHA}", "HEAD_SHA: pending") + body = body.replace("reviewer approved at head", "reviewer pending at head") + body = body.replace("approved /", "pending /") + result = ccv.assess_canonical_comment(body, context="pr_comment") + self.assertFalse(result["allowed"]) + self.assertTrue(result["extra_reasons"]) + + def test_ready_to_merge_valid_allowed(self): + result = ccv.assess_canonical_comment(_VALID_READY_TO_MERGE, context="pr_comment") + self.assertTrue(result["allowed"]) + + def test_superseded_requires_canonical_and_superseded_items(self): + body = _VALID_SUPERSEDED.replace("CANONICAL_ITEM: issue #495", "CANONICAL_ITEM:") + result = ccv.assess_canonical_comment(body, context="supersession") + self.assertFalse(result["allowed"]) + self.assertIn("CANONICAL_ITEM", result["missing_fields"]) + + def test_superseded_valid_allowed(self): + result = ccv.assess_canonical_comment(_VALID_SUPERSEDED, context="supersession") + self.assertTrue(result["allowed"]) + + def test_blocked_requires_unblock_condition(self): + body = _VALID_BLOCKED.replace( + "BLOCKERS: pytest failures; unblock after all validator tests pass", + "BLOCKERS: pytest failures", + ) + result = ccv.assess_canonical_comment(body) + self.assertFalse(result["allowed"]) + self.assertIn("BLOCKERS", result["vague_fields"]) + + def test_blocked_valid_allowed(self): + result = ccv.assess_canonical_comment(_VALID_BLOCKED) + self.assertTrue(result["allowed"]) + + def test_correction_lists_missing_fields(self): + result = ccv.assess_canonical_comment("ready for merge") + self.assertFalse(result["allowed"]) + message = result["correction_message"] + self.assertIn("Missing fields", message) + self.assertIn("WHO_IS_NEXT", message) + self.assertIn("Suggested template", message) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_final_report_validator.py b/tests/test_final_report_validator.py index 70121f4..5d9e526 100644 --- a/tests/test_final_report_validator.py +++ b/tests/test_final_report_validator.py @@ -573,6 +573,31 @@ class TestReconcilerCloseProof(unittest.TestCase): ) +class TestCanonicalCommentPostClaim(unittest.TestCase): + def test_blocks_post_claim_when_validator_rejected_in_report(self): + report = "\n".join([ + "## Controller Handoff", + "- MCP/Gitea mutations: issue comment posted", + "- Issue comments posted: 1 canonical state comment", + "canonical comment validation failed (fail closed before posting).", + '- canonical_comment_validation: {"allowed": false}', + ]) + result = assess_final_report_validator(report, "work_issue") + rule_ids = [f["rule_id"] for f in result["findings"]] + self.assertIn("shared.canonical_comment_post_claim", rule_ids) + self.assertTrue(result["blocked"]) + + def test_allows_none_when_validator_rejected_without_post_claim(self): + report = "\n".join([ + "## Controller Handoff", + "- Issue comments posted: none", + "canonical comment validation failed (fail closed before posting).", + ]) + result = assess_final_report_validator(report, "work_issue") + rule_ids = [f["rule_id"] for f in result["findings"]] + self.assertNotIn("shared.canonical_comment_post_claim", rule_ids) + + class TestEntryPoint(unittest.TestCase): def test_unknown_task_kind_blocks(self): result = assess_final_report_validator("report", "unknown_mode") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 503e879..dd1a20d 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -3102,6 +3102,36 @@ class TestIssueCommentTools(unittest.TestCase): self.assertFalse(result["success"]) mock_api.assert_not_called() + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_casual_comment_allowed(self, _auth, mock_api): + mock_api.return_value = self._comment(556, "gitea-author", "casual") + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + result = gitea_create_issue_comment( + issue_number=9, + body="Thanks, I will check this.", + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertTrue(result["performed"]) + mock_api.assert_called_once() + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_workflow_comment_missing_fields_blocked(self, _auth, mock_api): + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + result = gitea_create_issue_comment( + issue_number=9, + body="Blocked, author should fix.", + remote="prgs", + ) + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertIn("canonical_comment_validation", result) + self.assertFalse(result["canonical_comment_validation"]["allowed"]) + self.assertTrue(result["canonical_comment_validation"]["is_workflow_comment"]) + mock_api.assert_not_called() + @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_missing_issue_error_is_redacted(self, _auth, mock_api): From 95f77bb6485abc841ed999f6a7455cc41bdb8572 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 14:23:18 -0400 Subject: [PATCH 7/8] feat: add MCP runtime health dashboard to web UI (Closes #430) Adds read-only /runtime and /api/runtime routes showing active profile, identity, config model, git sync vs remote master, shell health, workflow/schema hashes, and stale-runtime warnings with #420 guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- docs/webui-local-dev.md | 14 +- tests/test_webui_runtime_health.py | 88 ++++++++ tests/test_webui_skeleton.py | 7 +- webui/app.py | 17 +- webui/runtime_health.py | 320 +++++++++++++++++++++++++++++ webui/runtime_views.py | 92 +++++++++ 6 files changed, 529 insertions(+), 9 deletions(-) create mode 100644 tests/test_webui_runtime_health.py create mode 100644 webui/runtime_health.py create mode 100644 webui/runtime_views.py diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 96225b6..23d4c56 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -43,7 +43,8 @@ Optional environment variables: | `/api/projects` | JSON registry export | | `/prompts` | Prompt library with per-prompt copy buttons (#428) | | `/api/prompts` | JSON prompt export with workflow hashes | -| `/runtime` | Stub — MCP runtime health (#430) | +| `/runtime` | MCP runtime health and stale detection (#430) | +| `/api/runtime` | JSON runtime health export | | `/audit` | Stub — report audit paste (#431) | | `/worktrees` | Stub — hygiene dashboard (#432) | | `/leases` | Lease and collision visibility (#433) | @@ -91,8 +92,17 @@ in-progress claim inventory (#268), reviewer PR lease comments when present and duplicate local branches per issue. Links to collision-history backend issues (#267, #268, #400, #407) are included. No lease acquire/release from UI. +## Runtime health (#430) + +`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry +project: active profile and role kind, authenticated identity (when credentials +are available), config model/mode, local vs remote `master` SHA sync, shell +health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the +checkout is behind merged safety-gate changes. Restart guidance links to #420; +no tokens or MCP restart actions are exposed. + ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_runtime_health.py b/tests/test_webui_runtime_health.py new file mode 100644 index 0000000..9fda3ee --- /dev/null +++ b/tests/test_webui_runtime_health.py @@ -0,0 +1,88 @@ +"""Tests for web UI runtime health dashboard (#430).""" +import sys +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.runtime_health import _role_kind, load_runtime_snapshot, snapshot_to_dict + + +def _mock_identity(_host: str): + return "jcwalker3", None + + +def _mock_git_sync(_repo: Path, _remote: str, _branch: str): + return "localsha123456", "remoteshaabcdef", 2 + + +class TestRuntimeHealthLoader(unittest.TestCase): + def test_role_kind_author(self): + allowed = ["gitea.pr.create", "gitea.branch.push", "gitea.read"] + forbidden = ["gitea.pr.merge", "gitea.pr.approve"] + self.assertEqual(_role_kind(allowed, forbidden), "author") + + def test_snapshot_with_mocks(self): + snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + self.assertEqual(snapshot.project_id, "gitea-tools") + self.assertEqual(snapshot.authenticated_username, "jcwalker3") + self.assertEqual(snapshot.commits_behind_master, 2) + self.assertIsNotNone(snapshot.stale_runtime_warning) + self.assertGreaterEqual(len(snapshot.workflow_hashes), 3) + self.assertGreaterEqual(len(snapshot.schema_hashes), 2) + self.assertIn("shell_use_allowed", snapshot.shell_health) + + def test_snapshot_dict_export(self): + snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + data = snapshot_to_dict(snapshot) + self.assertEqual(data["authenticated_username"], "jcwalker3") + self.assertEqual(data["commits_behind_master"], 2) + self.assertTrue(data["stale_runtime_warning"]) + + +class TestRuntimeRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + self.snapshot = load_runtime_snapshot( + resolve_username=_mock_identity, + git_sync=_mock_git_sync, + ) + self._patch = mock.patch( + "webui.app.load_runtime_snapshot", + return_value=self.snapshot, + ) + self._patch.start() + + def tearDown(self): + self._patch.stop() + + def test_runtime_page_renders_profile_and_warning(self): + response = self.client.get("/runtime") + self.assertEqual(response.status_code, 200) + self.assertIn("Runtime health", response.text) + self.assertIn("Active profile", response.text) + self.assertIn("Stale runtime warning", response.text) + self.assertIn("Workflow hashes", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_api_runtime_json(self): + response = self.client.get("/api/runtime") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["project_id"], "gitea-tools") + self.assertEqual(data["role_kind"], self.snapshot.role_kind) + self.assertEqual(data["commits_behind_master"], 2) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 953b368..e5b71b6 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -29,8 +29,13 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Operator console", response.text) self.assertIn("Read-only MVP", response.text) + def test_runtime_is_implemented(self): + response = self.client.get("/runtime") + self.assertEqual(response.status_code, 200) + self.assertIn("Runtime health", response.text) + def test_route_stubs_render(self): - for path in ("/runtime", "/audit"): + for path in ("/audit",): with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) diff --git a/webui/app.py b/webui/app.py index 4f4cb0f..fb1aab2 100644 --- a/webui/app.py +++ b/webui/app.py @@ -16,8 +16,10 @@ from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_views import render_prompt_detail, render_prompts_page from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict from webui.lease_views import render_leases_page -from webui.queue_loader import load_queue_snapshot, snapshot_to_dict +from webui.queue_loader import load_queue_snapshot, snapshot_to_dict as queue_snapshot_to_dict from webui.queue_views import render_queue_page +from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict +from webui.runtime_views import render_runtime_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -63,7 +65,7 @@ async def queue(_request: Request) -> HTMLResponse: async def api_queue(_request: Request) -> JSONResponse: - return JSONResponse(snapshot_to_dict(load_queue_snapshot())) + return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot())) async def projects(_request: Request) -> HTMLResponse: @@ -122,10 +124,12 @@ async def api_prompts(_request: Request) -> JSONResponse: async def runtime(_request: Request) -> HTMLResponse: - return _stub_page( - "Runtime", - "Runtime health will report MCP profile, preflight, and stale-server signals.", - ) + snapshot = load_runtime_snapshot() + return HTMLResponse(render_page(title="Runtime", body_html=render_runtime_page(snapshot))) + + +async def api_runtime(_request: Request) -> JSONResponse: + return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot())) async def audit(_request: Request) -> HTMLResponse: @@ -176,6 +180,7 @@ def create_app() -> Starlette: Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]), Route("/api/prompts", api_prompts, methods=["GET"]), Route("/runtime", runtime, methods=["GET"]), + Route("/api/runtime", api_runtime, methods=["GET"]), Route("/audit", audit, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]), Route("/leases", leases, methods=["GET"]), diff --git a/webui/runtime_health.py b/webui/runtime_health.py new file mode 100644 index 0000000..8f3b5d6 --- /dev/null +++ b/webui/runtime_health.py @@ -0,0 +1,320 @@ +"""Read-only MCP/runtime health snapshot for the web UI (#430).""" + +from __future__ import annotations + +import hashlib +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + +import gitea_config +import reconciler_profile +from gitea_auth import REMOTES, api_request, get_auth_header, get_profile, gitea_url + +from native_mcp_preference import shell_health_status + +from webui.project_registry import ProjectRecord, load_registry + +_RESTART_DOCS = "docs/llm-workflow-runbooks.md#issue-420-mcp-hot-reload" +_HOT_RELOAD_ISSUE = "#420" + + +@dataclass(frozen=True) +class FileHash: + label: str + path: str + sha256: str | None + error: str | None = None + + +@dataclass(frozen=True) +class RuntimeSnapshot: + project_id: str + repo_root: str + remote: str + host: str + profile_name: str + role_kind: str + config_model: str + profile_mode: str + profile_source: str + authenticated_username: str | None + identity_error: str | None + repo_sha: str | None + remote_master_sha: str | None + commits_behind_master: int | None + stale_runtime_warning: str | None + shell_health: dict[str, Any] + workflow_hashes: tuple[FileHash, ...] + schema_hashes: tuple[FileHash, ...] + restart_guidance: str + fetch_error: str | None = None + + +def _repo_root() -> Path: + override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip() + if override: + return Path(override).resolve() + return Path(__file__).resolve().parent.parent + + +def _host_from_url(remote_host: str) -> str: + parsed = urlparse(remote_host.strip()) + return parsed.netloc or remote_host.strip().rstrip("/") + + +def _remote_name_for_host(host: str) -> str: + for name, profile in REMOTES.items(): + if profile.get("host") == host: + return name + return "custom" + + +def _config_model(config: dict | None) -> str: + if config is None: + return "env-only" + version = config.get("version") + if version == 1: + return "v1" + if version == 2: + if "contexts" in config or config.get("shape") == "contexts": + return "v2-contexts" + return "v2-environments" + return f"unknown-version-{version}" + + +def _profile_source() -> str: + if os.environ.get("GITEA_PROFILE_NAME"): + return "env var" + if gitea_config.selected_profile_name(): + return "config file profile" + return "default" + + +def _role_kind(allowed: list[str], forbidden: list[str]) -> str: + if reconciler_profile.is_reconciler_profile(allowed, forbidden): + return "reconciler" + + def can(op: str) -> bool: + return gitea_config.check_operation(op, allowed, forbidden)[0] + + review = can("gitea.pr.approve") or can("gitea.pr.merge") + author = can("gitea.pr.create") or can("gitea.branch.push") + reconciler = can("gitea.pr.close") and not review and not author + if review and author: + return "mixed" + if review: + return "reviewer" + if reconciler: + return "reconciler" + if author: + return "author" + return "limited" + + +def _sha256_file(path: Path) -> FileHash: + label = path.name + try: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + return FileHash(label=label, path=str(path), sha256=digest) + except OSError as exc: + return FileHash(label=label, path=str(path), sha256=None, error=str(exc)) + + +def _run_git(repo: Path, *args: str) -> str | None: + try: + completed = subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() or None + except (OSError, subprocess.CalledProcessError): + return None + + +def _git_sync_status(repo: Path, remote: str, branch: str) -> tuple[str | None, str | None, int | None]: + local_sha = _run_git(repo, "rev-parse", "HEAD") + remote_sha = _run_git(repo, "rev-parse", f"{remote}/{branch}") + behind = None + if local_sha and remote_sha and local_sha != remote_sha: + count = _run_git(repo, "rev-list", "--count", f"HEAD..{remote}/{branch}") + if count is not None and count.isdigit(): + behind = int(count) + return local_sha, remote_sha, behind + + +def _resolve_username(host: str) -> tuple[str | None, str | None]: + auth = get_auth_header(host) + if not auth: + return None, f"Gitea credentials unavailable for {host}" + try: + data = api_request("GET", gitea_url(host, "/api/v1/user"), auth) + except Exception as exc: # noqa: BLE001 — operator-visible identity errors + return None, f"Identity lookup failed: {exc}" + username = (data or {}).get("login") + if not username: + return None, "Authenticated identity could not be resolved" + return str(username), None + + +def _stale_warning(commits_behind: int | None) -> str | None: + if commits_behind is None or commits_behind <= 0: + return None + return ( + f"Local checkout is {commits_behind} commit(s) behind remote master. " + "Merged safety-gate changes may require an MCP server restart or profile " + f"reload — see {_HOT_RELOAD_ISSUE} / {_RESTART_DOCS}." + ) + + +def load_runtime_snapshot( + project_id: str | None = None, + *, + resolve_username: Callable[[str], tuple[str | None, str | None]] | None = None, + git_sync: Callable[[Path, str, str], tuple[str | None, str | None, int | None]] | None = None, +) -> RuntimeSnapshot: + """Build a read-only runtime health snapshot for the default registry project.""" + registry = load_registry() + project: ProjectRecord | None = None + if project_id: + for entry in registry.projects: + if entry.id == project_id: + project = entry + break + else: + project = registry.projects[0] if registry.projects else None + + if project is None: + return RuntimeSnapshot( + project_id=project_id or "", + repo_root=str(_repo_root()), + remote="", + host="", + profile_name="", + role_kind="", + config_model="", + profile_mode="", + profile_source="", + authenticated_username=None, + identity_error="project not found in registry", + repo_sha=None, + remote_master_sha=None, + commits_behind_master=None, + stale_runtime_warning=None, + shell_health={}, + workflow_hashes=(), + schema_hashes=(), + restart_guidance=_RESTART_DOCS, + fetch_error="project not found in registry", + ) + + repo = _repo_root() + host = _host_from_url(project.remote_host) + remote = _remote_name_for_host(host) + profile = get_profile() + allowed = profile.get("allowed_operations") or [] + forbidden = profile.get("forbidden_operations") or [] + config = None + try: + config = gitea_config.load_config() + except gitea_config.ConfigError as exc: + return RuntimeSnapshot( + project_id=project.id, + repo_root=str(repo), + remote=remote, + host=host, + profile_name=profile.get("profile_name") or "", + role_kind=_role_kind(allowed, forbidden), + config_model="config-error", + profile_mode="unknown", + profile_source=_profile_source(), + authenticated_username=None, + identity_error=str(exc), + repo_sha=None, + remote_master_sha=None, + commits_behind_master=None, + stale_runtime_warning=None, + shell_health=shell_health_status(), + workflow_hashes=(), + schema_hashes=(), + restart_guidance=_RESTART_DOCS, + fetch_error=str(exc), + ) + + identity_fn = resolve_username or _resolve_username + username, identity_error = identity_fn(host) + + git_fn = git_sync or _git_sync_status + remote_ref = "prgs" if remote == "prgs" else "origin" + local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch) + + workflow_hashes = tuple( + _sha256_file(repo / rel_path) + for rel_path in project.workflow_paths.values() + ) + schema_hashes = tuple( + _sha256_file(repo / rel_path) + for rel_path in project.schema_paths.values() + ) + + switching = gitea_config.is_runtime_switching_enabled() + return RuntimeSnapshot( + project_id=project.id, + repo_root=str(repo), + remote=remote, + host=host, + profile_name=str(profile.get("profile_name") or ""), + role_kind=_role_kind(allowed, forbidden), + config_model=_config_model(config), + profile_mode="dynamic-profile" if switching else "static-profile", + profile_source=_profile_source(), + authenticated_username=username, + identity_error=identity_error, + repo_sha=local_sha, + remote_master_sha=remote_sha, + commits_behind_master=behind, + stale_runtime_warning=_stale_warning(behind), + shell_health=shell_health_status(), + workflow_hashes=workflow_hashes, + schema_hashes=schema_hashes, + restart_guidance=_RESTART_DOCS, + ) + + +def snapshot_to_dict(snapshot: RuntimeSnapshot) -> dict[str, Any]: + def _hash_row(item: FileHash) -> dict[str, Any]: + return { + "label": item.label, + "path": item.path, + "sha256": item.sha256, + "error": item.error, + } + + return { + "project_id": snapshot.project_id, + "repo_root": snapshot.repo_root, + "remote": snapshot.remote, + "host": snapshot.host, + "profile_name": snapshot.profile_name, + "role_kind": snapshot.role_kind, + "config_model": snapshot.config_model, + "profile_mode": snapshot.profile_mode, + "profile_source": snapshot.profile_source, + "authenticated_username": snapshot.authenticated_username, + "identity_error": snapshot.identity_error, + "repo_sha": snapshot.repo_sha, + "remote_master_sha": snapshot.remote_master_sha, + "commits_behind_master": snapshot.commits_behind_master, + "stale_runtime_warning": snapshot.stale_runtime_warning, + "shell_health": snapshot.shell_health, + "workflow_hashes": [_hash_row(item) for item in snapshot.workflow_hashes], + "schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes], + "restart_guidance": snapshot.restart_guidance, + "fetch_error": snapshot.fetch_error, + } \ No newline at end of file diff --git a/webui/runtime_views.py b/webui/runtime_views.py new file mode 100644 index 0000000..b057248 --- /dev/null +++ b/webui/runtime_views.py @@ -0,0 +1,92 @@ +"""HTML views for MCP runtime health (#430).""" + +from __future__ import annotations + +import html + +from webui.runtime_health import FileHash, RuntimeSnapshot + + +def _hash_rows(items: tuple[FileHash, ...]) -> str: + if not items: + return "<p class='muted'>No hashes available.</p>" + rows = [] + for item in items: + digest = item.sha256 or item.error or "unavailable" + rows.append( + "<tr>" + f"<td>{html.escape(item.label)}</td>" + f"<td><code>{html.escape(item.path)}</code></td>" + f"<td><code>{html.escape(digest[:16] if item.sha256 else digest)}</code></td>" + "</tr>" + ) + return ( + "<table class='registry'><thead><tr>" + "<th>Artifact</th><th>Path</th><th>SHA-256</th>" + "</tr></thead><tbody>" + f"{''.join(rows)}</tbody></table>" + ) + + +def render_runtime_page(snapshot: RuntimeSnapshot) -> str: + error_block = "" + if snapshot.fetch_error: + error_block = ( + '<div class="stub"><p><strong>Runtime snapshot incomplete:</strong> ' + f"{html.escape(snapshot.fetch_error)}</p></div>" + ) + + identity = snapshot.authenticated_username or "unresolved" + if snapshot.identity_error: + identity = f"unresolved ({snapshot.identity_error})" + + stale_block = "" + if snapshot.stale_runtime_warning: + stale_block = ( + '<div class="stub"><p><strong>Stale runtime warning:</strong> ' + f"{html.escape(snapshot.stale_runtime_warning)}</p></div>" + ) + + shell = snapshot.shell_health or {} + shell_summary = ( + f"shell_use_allowed={shell.get('shell_use_allowed')} · " + f"failures={shell.get('consecutive_spawn_failures')} · " + f"hard_stopped={shell.get('hard_stopped')}" + ) + + return ( + "<h2>Runtime health</h2>" + f"<p class='meta'>Project <code>{html.escape(snapshot.project_id)}</code> · " + f"host <code>{html.escape(snapshot.host)}</code> · " + f"repo root <code>{html.escape(snapshot.repo_root)}</code></p>" + f"{error_block}" + f"{stale_block}" + "<h3>Active profile</h3>" + "<table class='detail'>" + f"<tr><th>Profile</th><td><code>{html.escape(snapshot.profile_name)}</code></td></tr>" + f"<tr><th>Role kind</th><td>{html.escape(snapshot.role_kind)}</td></tr>" + f"<tr><th>Identity</th><td>{html.escape(identity)}</td></tr>" + f"<tr><th>Config model</th><td>{html.escape(snapshot.config_model)}</td></tr>" + f"<tr><th>Profile mode</th><td>{html.escape(snapshot.profile_mode)} " + f"({html.escape(snapshot.profile_source)})</td></tr>" + "</table>" + "<h3>Server code sync</h3>" + "<table class='detail'>" + f"<tr><th>Local HEAD</th><td><code>{html.escape(snapshot.repo_sha or 'unknown')}</code></td></tr>" + f"<tr><th>Remote {html.escape(snapshot.remote)}/{html.escape('master')}</th>" + f"<td><code>{html.escape(snapshot.remote_master_sha or 'unknown')}</code></td></tr>" + f"<tr><th>Commits behind</th><td>{snapshot.commits_behind_master if snapshot.commits_behind_master is not None else 'unknown'}</td></tr>" + "</table>" + "<h3>Shell health</h3>" + f"<p class='meta'>{html.escape(shell_summary)}</p>" + f"<p class='muted'>{html.escape(str(shell.get('safe_next_action') or ''))}</p>" + "<h3>Workflow hashes</h3>" + f"{_hash_rows(snapshot.workflow_hashes)}" + "<h3>Schema hashes</h3>" + f"{_hash_rows(snapshot.schema_hashes)}" + "<h3>Restart / reload</h3>" + "<p class='muted'>MVP is read-only — restart MCP servers from your IDE/operator " + "workflow. Related issue: <code>#420</code>. Guidance: " + f"<code>{html.escape(snapshot.restart_guidance)}</code></p>" + "<p class='muted'>This page does not expose tokens or perform MCP restarts.</p>" + ) \ No newline at end of file From ffa1ff95cc2cf8d77ca2e6c90d903b2407fa7dc4 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 23:53:25 -0400 Subject: [PATCH 8/8] fix: import ISSUE_LOCK_FILE from issue_lock_provenance The conflict-resolution rebase incorrectly kept ISSUE_LOCK_FILE in the merged_cleanup_reconcile import, but that symbol was moved to issue_lock_provenance on master. This broke webui import collection. Fixes the reviewer-reported regression on PR #448. --- webui/lease_loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/lease_loader.py b/webui/lease_loader.py index 548aec3..318a46f 100644 --- a/webui/lease_loader.py +++ b/webui/lease_loader.py @@ -11,8 +11,8 @@ from urllib.parse import urlparse from gitea_auth import api_fetch_page, get_auth_header, repo_api_url from issue_claim_heartbeat import build_claim_inventory -from merged_cleanup_reconcile import read_issue_lock from issue_lock_provenance import ISSUE_LOCK_FILE +from merged_cleanup_reconcile import read_issue_lock from webui.project_registry import ProjectRecord, load_registry from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs