diff --git a/.github/sync-manifest.yml b/.github/sync-manifest.yml index 0d159b962..652a32914 100644 --- a/.github/sync-manifest.yml +++ b/.github/sync-manifest.yml @@ -584,6 +584,7 @@ scripts: # (format step: `python scripts/langchain/issue_formatter.py`), which does NOT # runtime-fetch scripts/langchain. Must stay copy-synced. delivery: copy + template_sync: exact - source: scripts/langchain/issue_pr_context.py description: "Issue/PR context assembly helper with shared token budget caps" diff --git a/langsmith-fleet-worker-attempt.json b/langsmith-fleet-worker-attempt.json index 7b15a95be..a370bf0da 100644 --- a/langsmith-fleet-worker-attempt.json +++ b/langsmith-fleet-worker-attempt.json @@ -1,13 +1,13 @@ { "agent": "codex", "cli_version": "0.144.1", - "emitted_at": "2026-08-09T02:16:27.270949Z", + "emitted_at": "2026-08-09T02:47:58.915158Z", "execution_profile": "codex-default", "fallback_models": [ "gpt-5.5" ], "operation_role": "worker", - "pr_number": "2998", + "pr_number": "2999", "requested_model": "gpt-5.6-terra", "runner": "reusable-codex-run", "schema": "langsmith-fleet/v1", diff --git a/scripts/langchain/issue_formatter.py b/scripts/langchain/issue_formatter.py index 4f61d51aa..be59c8ad0 100755 --- a/scripts/langchain/issue_formatter.py +++ b/scripts/langchain/issue_formatter.py @@ -10,10 +10,12 @@ from __future__ import annotations import argparse +import importlib.util import json import os import re import sys +from functools import lru_cache from pathlib import Path from typing import Any @@ -46,6 +48,20 @@ # ~4 chars per token, so 50k chars ≈ 12.5k tokens, leaving headroom for prompt + output MAX_ISSUE_BODY_SIZE = 50000 + +@lru_cache(maxsize=1) +def _issue_format_validator() -> Any: + """Load the fleet's single issue-format definition without forking it.""" + validator_path = Path(__file__).resolve().parents[2] / ".github/scripts/issue_format.py" + spec = importlib.util.spec_from_file_location("_fleet_issue_format", validator_path) + if spec is None or spec.loader is None: # pragma: no cover - repository invariant + raise RuntimeError(f"Cannot load canonical issue-format validator: {validator_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + # Workflow tags written into the reuse marker. Tagging every stage of the # auto-pilot format -> optimize -> apply chain lets any stage detect a body it (or # a sibling stage) already formatted and skip re-deriving it, which is the @@ -151,6 +167,7 @@ def _strip_reuse_marker(text: str) -> str: LIST_ITEM_REGEX = re.compile(r"^(\s*)([-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$") CHECKBOX_REGEX = re.compile(r"^\[([ xX])\]\s*(.*)$") +VERIFY_HINT_REGEX = re.compile(r"\(verify:\s*([^\n)]+)\)", re.IGNORECASE) def _context_token_budget() -> int: @@ -355,6 +372,22 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str: impl_text = join_or_placeholder(impl_lines, "_Not provided._") tasks_text = join_or_placeholder(tasks_lines, "- [ ] _Not provided._") acceptance_text = join_or_placeholder(acceptance_lines, "- [ ] _Not provided._") + try: + validator = _issue_format_validator() + except (ImportError, OSError, RuntimeError, SyntaxError): + # Consumer checkouts can be mid-sync or missing the canonical validator. + # Keep the pre-validator fallback usable instead of failing the formatter. + validator = None + if validator is not None and not validator.GATE.search(acceptance_text): + verify_hint = VERIFY_HINT_REGEX.search(tasks_text) + if verify_hint: + command = verify_hint.group(1).strip().strip("`") + if command.startswith("pytest "): + command = f"python3 -m {command}" + acceptance_text = ( + f"{acceptance_text}\n" + f"- [ ] Run `{command}` and capture the command output in PR validation evidence." + ) parts = [ "## Why", @@ -387,8 +420,12 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str: def _formatted_output_valid(text: str) -> bool: if not text: return False - required = ["## Tasks", "## Acceptance Criteria"] - return all(section in text for section in required) + try: + return bool(_issue_format_validator().validate(text).ok) + except (ImportError, OSError, RuntimeError, SyntaxError): + # Preserve the former heading-only behavior until the copy-synced + # validator becomes available again. + return all(section in text for section in ("## Tasks", "## Acceptance Criteria")) def _select_code_fence(text: str) -> str: @@ -398,12 +435,10 @@ def _select_code_fence(text: str) -> str: ORIGINAL_ISSUE_SUMMARY = "Original Issue" -# Matches an Original-Issue
block (and trailing whitespace) so it can -# be replaced rather than nested. Non-greedy body, anchored to the closing tag. -_ORIGINAL_ISSUE_BLOCK_RE = re.compile( - r"
\s*Original Issue.*?
[ \t]*\n?", - re.DOTALL | re.IGNORECASE, +_ORIGINAL_ISSUE_OPEN_RE = re.compile( + r"]*>\s*Original Issue", re.IGNORECASE ) +_DETAILS_TAG_RE = re.compile(r"]*>", re.IGNORECASE) # Captures the verbatim text fenced inside an Original-Issue block, so an # already-embedded original can be recovered (and re-embedded once) instead of # being wrapped again. @@ -415,8 +450,25 @@ def _select_code_fence(text: str) -> str: def _strip_original_issue_blocks(text: str) -> str: - """Remove any embedded Original-Issue
block(s) from ``text``.""" - return _ORIGINAL_ISSUE_BLOCK_RE.sub("", text).rstrip() + """Remove complete embedded Original-Issue blocks, including nested details.""" + kept: list[str] = [] + cursor = 0 + while match := _ORIGINAL_ISSUE_OPEN_RE.search(text, cursor): + kept.append(text[cursor : match.start()]) + depth = 1 + end = match.end() + for tag in _DETAILS_TAG_RE.finditer(text, match.end()): + depth += -1 if tag.group(0).startswith(" str | None: @@ -705,6 +757,7 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any pass formatted = _format_issue_fallback(issue_body) + needs_refinement = not _formatted_output_valid(formatted) # NOTE: Task decomposition is now handled by agents:optimize step # which uses LLM for intelligent splitting. Don't do heuristic # splitting here - it causes task explosion (issue #805, #1143). @@ -716,6 +769,7 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any "provider_used": None, "used_llm": False, "validation_audit": audit, + "needs_refinement": needs_refinement, } diff --git a/templates/consumer-repo/scripts/langchain/issue_formatter.py b/templates/consumer-repo/scripts/langchain/issue_formatter.py index 367510f6e..be59c8ad0 100755 --- a/templates/consumer-repo/scripts/langchain/issue_formatter.py +++ b/templates/consumer-repo/scripts/langchain/issue_formatter.py @@ -10,28 +10,96 @@ from __future__ import annotations import argparse +import importlib.util import json import os import re import sys +from functools import lru_cache from pathlib import Path from typing import Any try: + from scripts.langchain._llm_client import get_llm_client as _get_llm_client from scripts.langchain.checklist_utils import is_placeholder_checklist_text from scripts.langchain.injection_guard import check_prompt_injection - from scripts.langchain.issue_pr_context import ContextOptions, build_issue_context + from scripts.langchain.issue_pr_context import ( + ContextOptions, + already_conformant, + build_formatted_body_marker, + build_issue_context, + reuse_formatted_body, + ) from scripts.langchain.trace_utils import TraceInfo, invoke_with_trace except ImportError: # pragma: no cover - fallback for direct invocation + from _llm_client import get_llm_client as _get_llm_client from checklist_utils import is_placeholder_checklist_text from injection_guard import check_prompt_injection - from issue_pr_context import ContextOptions, build_issue_context + from issue_pr_context import ( + ContextOptions, + already_conformant, + build_formatted_body_marker, + build_issue_context, + reuse_formatted_body, + ) from trace_utils import TraceInfo, invoke_with_trace # Maximum issue body size to prevent OpenAI rate limit errors (30k TPM limit) # ~4 chars per token, so 50k chars ≈ 12.5k tokens, leaving headroom for prompt + output MAX_ISSUE_BODY_SIZE = 50000 + +@lru_cache(maxsize=1) +def _issue_format_validator() -> Any: + """Load the fleet's single issue-format definition without forking it.""" + validator_path = Path(__file__).resolve().parents[2] / ".github/scripts/issue_format.py" + spec = importlib.util.spec_from_file_location("_fleet_issue_format", validator_path) + if spec is None or spec.loader is None: # pragma: no cover - repository invariant + raise RuntimeError(f"Cannot load canonical issue-format validator: {validator_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +# Workflow tags written into the reuse marker. Tagging every stage of the +# auto-pilot format -> optimize -> apply chain lets any stage detect a body it (or +# a sibling stage) already formatted and skip re-deriving it, which is the +# primary defense against re-run amplification. +REUSE_MARKER_WORKFLOWS = ( + "agents-auto-pilot", + "agents-issue-optimizer", + "agents-63-issue-intake", + "issue_formatter", + "issue_optimizer", +) + + +def _with_reuse_marker(formatted: str) -> str: + """Append (or refresh) the reuse marker that lets later stages skip re-formatting. + + The marker stores a sha256 fingerprint of the formatted body (hash-only). A + stale marker (body edited after formatting) simply fails the hash check + downstream and is ignored, so this is always safe to (re)write. + """ + body = _strip_reuse_marker(formatted).rstrip() + marker = build_formatted_body_marker( + workflows=list(REUSE_MARKER_WORKFLOWS), + formatted_body=body, + embed_body=False, # hash-only: the formatted body is written back in full anyway + ) + return f"{body}\n\n{marker}\n" + + +def _strip_reuse_marker(text: str) -> str: + try: + from scripts.langchain.issue_pr_context import MARKER_RE + except ImportError: # pragma: no cover - fallback for direct invocation + from issue_pr_context import MARKER_RE + + return MARKER_RE.sub("", text).rstrip() + + ISSUE_FORMATTER_PROMPT = """ You are a formatting assistant. Convert the raw GitHub issue body into the AGENT_ISSUE_TEMPLATE format with the exact section headers in order: @@ -52,6 +120,14 @@ for Tasks/Acceptance). - Output ONLY the formatted markdown with these sections (no extra commentary). +Length & scope discipline (improve clarity, do NOT inflate): +- Improve clarity WITHOUT increasing total length. Do not add a task, criterion, + or sentence unless it fills a genuinely missing mandatory section. +- Preserve scope; do NOT invent file paths, functions, tests, or criteria the + source does not imply, and do NOT manufacture prose to fill placeholders. +- Never restate the same point under two headings; never split a task that is + already a single ~10-minute action. + Raw issue body: {issue_body} """.strip() @@ -91,6 +167,7 @@ LIST_ITEM_REGEX = re.compile(r"^(\s*)([-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$") CHECKBOX_REGEX = re.compile(r"^\[([ xX])\]\s*(.*)$") +VERIFY_HINT_REGEX = re.compile(r"\(verify:\s*([^\n)]+)\)", re.IGNORECASE) def _context_token_budget() -> int: @@ -126,24 +203,6 @@ def _load_prompt() -> str: return base_prompt -def _get_llm_client(force_openai: bool = False) -> tuple[object, str] | None: - """Get LLM client, trying GitHub Models first (cheaper), then OpenAI. - - Args: - force_openai: If True, skip GitHub Models and use OpenAI directly. - Use this for retry after GitHub Models 401 error. - """ - try: - from tools.langchain_client import build_chat_client - except ImportError: - return None - - resolved = build_chat_client(force_openai=force_openai) - if not resolved: - return None - return resolved.client, resolved.provider - - def _normalize_heading(text: str) -> str: cleaned = re.sub(r"[#*_:]+", " ", text).strip().lower() cleaned = re.sub(r"\s+", " ", cleaned) @@ -313,6 +372,22 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str: impl_text = join_or_placeholder(impl_lines, "_Not provided._") tasks_text = join_or_placeholder(tasks_lines, "- [ ] _Not provided._") acceptance_text = join_or_placeholder(acceptance_lines, "- [ ] _Not provided._") + try: + validator = _issue_format_validator() + except (ImportError, OSError, RuntimeError, SyntaxError): + # Consumer checkouts can be mid-sync or missing the canonical validator. + # Keep the pre-validator fallback usable instead of failing the formatter. + validator = None + if validator is not None and not validator.GATE.search(acceptance_text): + verify_hint = VERIFY_HINT_REGEX.search(tasks_text) + if verify_hint: + command = verify_hint.group(1).strip().strip("`") + if command.startswith("pytest "): + command = f"python3 -m {command}" + acceptance_text = ( + f"{acceptance_text}\n" + f"- [ ] Run `{command}` and capture the command output in PR validation evidence." + ) parts = [ "## Why", @@ -345,8 +420,12 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str: def _formatted_output_valid(text: str) -> bool: if not text: return False - required = ["## Tasks", "## Acceptance Criteria"] - return all(section in text for section in required) + try: + return bool(_issue_format_validator().validate(text).ok) + except (ImportError, OSError, RuntimeError, SyntaxError): + # Preserve the former heading-only behavior until the copy-synced + # validator becomes available again. + return all(section in text for section in ("## Tasks", "## Acceptance Criteria")) def _select_code_fence(text: str) -> str: @@ -355,14 +434,91 @@ def _select_code_fence(text: str) -> str: return "`" * fence_len +ORIGINAL_ISSUE_SUMMARY = "Original Issue" +_ORIGINAL_ISSUE_OPEN_RE = re.compile( + r"]*>\s*Original Issue", re.IGNORECASE +) +_DETAILS_TAG_RE = re.compile(r"]*>", re.IGNORECASE) +# Captures the verbatim text fenced inside an Original-Issue block, so an +# already-embedded original can be recovered (and re-embedded once) instead of +# being wrapped again. +_ORIGINAL_ISSUE_INNER_RE = re.compile( + r"
\s*Original Issue\s*" + r"(?P`{3,})text\n(?P.*?)\n(?P=fence)\s*
", + re.DOTALL | re.IGNORECASE, +) + + +def _strip_original_issue_blocks(text: str) -> str: + """Remove complete embedded Original-Issue blocks, including nested details.""" + kept: list[str] = [] + cursor = 0 + while match := _ORIGINAL_ISSUE_OPEN_RE.search(text, cursor): + kept.append(text[cursor : match.start()]) + depth = 1 + end = match.end() + for tag in _DETAILS_TAG_RE.finditer(text, match.end()): + depth += -1 if tag.group(0).startswith(" str | None: + """Return the deepest verbatim Original-Issue payload embedded in ``text``. + + Nested blocks (from prior runaway cycles) are unwrapped layer by layer so the + true original is recovered, not a copy-of-a-copy. + """ + inner: str | None = None + current = text + while True: + match = _ORIGINAL_ISSUE_INNER_RE.search(current) + if not match: + break + inner = match.group("inner") + current = inner + return inner + + def _append_raw_issue_section(formatted: str, issue_body: str) -> str: - raw = issue_body.strip() + """Embed the verbatim original issue once, idempotently. + + Earlier behavior only checked whether the *input* already contained an + Original-Issue block, which let the block nest across auto-pilot cycles + (each pass re-wrapped the whole prior body — the 5-level nesting seen in + incident #1135). This version is idempotent: it recovers the innermost + verbatim original (from either the raw source or an already-embedded block), + strips every Original-Issue block from the formatted output, then appends + exactly one fresh block. Re-running on already-embedded output reproduces the + same single block. + """ + # Recover the verbatim original, preferring the most authoritative source: + # 1. the innermost embedded original in the raw source (the canonical input), + # 2. the raw source minus any Original-Issue wrapper, + # 3. the innermost embedded original in the formatted output (covers the + # edge where the output already carries a block but the raw arg does not + # — the exact pre-fix nesting vector). + # This preserves the true original instead of re-embedding reformatted text. + raw = _innermost_original_issue(issue_body) + if raw is None: + raw = _strip_original_issue_blocks(issue_body.strip()) + raw = raw.strip() if not raw: - return formatted - marker = "Original Issue" - # Check INPUT body, not output - if input already has Original Issue, don't nest another - if marker in raw: - return formatted + recovered = _innermost_original_issue(formatted) + raw = recovered.strip() if recovered else "" + formatted_wo_block = _strip_original_issue_blocks(formatted) + if not raw: + # Nothing to embed. If the formatted body already had a block it has been + # stripped above; return the cleaned form so no stale nested copy remains. + return formatted_wo_block if ORIGINAL_ISSUE_SUMMARY in formatted else formatted fence = _select_code_fence(raw) details = ( "\n\n
\n" @@ -370,7 +526,7 @@ def _append_raw_issue_section(formatted: str, issue_body: str) -> str: f"{fence}text\n{raw}\n{fence}\n" "
" ) - return f"{formatted.rstrip()}{details}\n" + return f"{formatted_wo_block.rstrip()}{details}\n" def _extract_tasks_from_formatted(body: str) -> list[str]: @@ -473,6 +629,39 @@ def _is_github_models_auth_error(exc: Exception) -> bool: return "401" in exc_str and "models" in exc_str +def _reuse_already_formatted(issue_body: str, workflow: str) -> dict[str, Any] | None: + """Return a short-circuit result if ``issue_body`` is already formatted. + + Two idempotency signals, in order of trust: + + 1. A reuse marker whose embedded hash matches the visible body — the body is + byte-identical to a prior formatter output for this workflow chain. + 2. The body is structurally conformant (all template sections + an embedded + Original-Issue block). + + In either case the body is returned unchanged (modulo a refreshed marker) so + no LLM rewrite occurs. Returns ``None`` when the body still needs formatting. + """ + reused = reuse_formatted_body({"body": issue_body}, workflow) + if reused is not None: + return { + "formatted_body": _with_reuse_marker(reused), + "provider_used": None, + "used_llm": False, + "skipped": "reused_marker", + "validation_audit": None, + } + if already_conformant(issue_body): + return { + "formatted_body": _with_reuse_marker(issue_body), + "provider_used": None, + "used_llm": False, + "skipped": "already_conformant", + "validation_audit": None, + } + return None + + def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any]: if not issue_body: issue_body = "" @@ -487,7 +676,17 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any "guard_reason": guard_result["reason"], } - issue_body = _capped_issue_body(issue_body, _context_workflow("issue_formatter")) + # Idempotency / anti-amplification: before re-deriving anything, check whether + # this body has already been formatted. Re-formatting an already-conformant + # body only paraphrases prior output and is the primary runaway-expansion + # vector (incidents #1135/#1143). Done on the *uncapped* body so detection + # still works on large already-formatted issues. + workflow = _context_workflow("issue_formatter") + reuse = _reuse_already_formatted(issue_body, workflow) + if reuse is not None: + return reuse + + issue_body = _capped_issue_body(issue_body, workflow) # Check size before processing to avoid rate limit errors if len(issue_body) > MAX_ISSUE_BODY_SIZE: @@ -544,6 +743,7 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any # splitting here - it causes task explosion (issue #805, #1143). formatted, audit = _validate_and_refine_tasks(formatted, use_llm=use_llm) formatted = _append_raw_issue_section(formatted, issue_body) + formatted = _with_reuse_marker(formatted) result = { "formatted_body": formatted, "provider_used": provider, @@ -557,16 +757,19 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any pass formatted = _format_issue_fallback(issue_body) + needs_refinement = not _formatted_output_valid(formatted) # NOTE: Task decomposition is now handled by agents:optimize step # which uses LLM for intelligent splitting. Don't do heuristic # splitting here - it causes task explosion (issue #805, #1143). formatted, audit = _validate_and_refine_tasks(formatted, use_llm=use_llm) formatted = _append_raw_issue_section(formatted, issue_body) + formatted = _with_reuse_marker(formatted) return { "formatted_body": formatted, "provider_used": None, "used_llm": False, "validation_audit": audit, + "needs_refinement": needs_refinement, } diff --git a/tests/scripts/test_issue_formatter.py b/tests/scripts/test_issue_formatter.py index c1c88dcbd..ff4666afd 100644 --- a/tests/scripts/test_issue_formatter.py +++ b/tests/scripts/test_issue_formatter.py @@ -11,6 +11,10 @@ from scripts.langchain.issue_pr_context import reuse_formatted_body +def _canonical_issue_format(): + return issue_formatter._issue_format_validator() + + def _install_fake_langchain(monkeypatch: pytest.MonkeyPatch, mock_chain: mock.MagicMock) -> None: mock_template = mock.MagicMock() mock_template.__or__ = mock.MagicMock(return_value=mock_chain) @@ -64,6 +68,23 @@ def test_format_issue_fallback_adds_sections_and_checkboxes() -> None: assert "- [ ] label transition works" in formatted +def test_format_issue_fallback_adds_acceptance_gate_when_only_tasks_have_verify_hint() -> None: + raw = """## Tasks +- [ ] Update `scripts/langchain/issue_formatter.py` and run `(verify: pytest tests/scripts/test_issue_formatter.py)`. + +## Acceptance Criteria +- [ ] Formatter preserves the source acceptance prose. +""" + + formatted = issue_formatter.format_issue_body(raw, use_llm=False)["formatted_body"] + acceptance = _extract_section(formatted, "Acceptance Criteria") + + assert "python3 -m pytest tests/scripts/test_issue_formatter.py" in acceptance + assert "Formatter preserves the source acceptance prose." in acceptance + assert _canonical_issue_format().GATE.search(acceptance) + assert _canonical_issue_format().validate(formatted).ok is True + + def test_format_issue_fallback_preserves_tasks_without_decomposition() -> None: """Formatter preserves tasks as-is; decomposition is done by agents:optimize step. @@ -112,7 +133,10 @@ def test_format_issue_fallback_uses_placeholders() -> None: acceptance = _extract_section(formatted, "Acceptance Criteria") assert tasks == "- [ ] _Not provided._" - assert acceptance == "- [ ] _Not provided._" + assert acceptance.startswith("- [ ] _Not provided._") + assert "python3 -m pytest" not in acceptance + assert _canonical_issue_format().validate(formatted).ok is False + assert result["needs_refinement"] is True def test_normalize_checklist_lines_drops_placeholder_checkboxes() -> None: @@ -224,7 +248,11 @@ def test_format_issue_body_llm_path_includes_raw_issue(monkeypatch: pytest.Monke mock_client = mock.MagicMock() mock_chain = mock.MagicMock() mock_response = mock.MagicMock() - mock_response.content = "## Tasks\n- [ ] Do it\n\n## Acceptance Criteria\n- [ ] Done" + mock_response.content = ( + "## Tasks\n- [ ] Update `scripts/langchain/issue_formatter.py`.\n\n" + "## Acceptance Criteria\n" + "- [ ] `pytest tests/scripts/test_issue_formatter.py` passes." + ) mock_response.response_metadata = {"run_id": "trace-format"} mock_chain.invoke.return_value = mock_response @@ -242,6 +270,42 @@ def test_format_issue_body_llm_path_includes_raw_issue(monkeypatch: pytest.Monke assert "Raw issue text" in result["formatted_body"] +def test_formatted_output_valid_uses_canonical_contract() -> None: + invalid = "## Tasks\n- [ ] Do it\n\n## Acceptance Criteria\n- [ ] Done" + valid = ( + "## Tasks\n- [ ] Update `scripts/langchain/issue_formatter.py`.\n\n" + "## Acceptance Criteria\n" + "- [ ] `pytest tests/scripts/test_issue_formatter.py` passes." + ) + + assert issue_formatter._formatted_output_valid(invalid) is False + assert issue_formatter._formatted_output_valid(valid) is True + + +def test_formatter_degrades_to_heading_validation_when_validator_cannot_load( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A briefly incomplete consumer sync must not make issue formatting crash.""" + monkeypatch.setattr( + issue_formatter, + "_issue_format_validator", + mock.MagicMock(side_effect=OSError("validator unavailable")), + ) + raw = """## Tasks + +- [ ] Run `(verify: pytest tests/scripts/test_issue_formatter.py)`. + +## Acceptance Criteria + +- [ ] Preserve a heading-only fallback while the validator is unavailable. +""" + + formatted = issue_formatter._format_issue_fallback(raw) + + assert issue_formatter._formatted_output_valid(formatted) is True + assert "PR validation evidence" not in formatted + + def test_format_issue_body_llm_invalid_output_falls_back(monkeypatch: pytest.MonkeyPatch) -> None: mock_client = mock.MagicMock() mock_chain = mock.MagicMock() @@ -552,3 +616,36 @@ def test_append_raw_issue_section_collapses_nested_blocks() -> None: out = issue_formatter._append_raw_issue_section("## Tasks\n\n- [ ] x", nested) assert out.count("Original Issue") == 1 assert out.count("TRUE ORIGINAL") == 1 + + +def test_strip_original_issue_blocks_removes_balanced_nested_details() -> None: + nested = """## Why + +Keep this text. + +
+Original Issue + +```text +outer +
+Original Issue + +```text +inner +``` +
+``` +
+ +## Scope + +Keep this too. +""" + + stripped = issue_formatter._strip_original_issue_blocks(nested) + + assert "Keep this text." in stripped + assert "Keep this too." in stripped + assert "Original Issue" not in stripped + assert "
" not in stripped