diff --git a/.github/aicodingflow-tests/test_prepare_issue_triage_context.py b/.github/aicodingflow-tests/test_prepare_issue_triage_context.py
index 4f2a24b..1583bbe 100644
--- a/.github/aicodingflow-tests/test_prepare_issue_triage_context.py
+++ b/.github/aicodingflow-tests/test_prepare_issue_triage_context.py
@@ -157,6 +157,110 @@ def test_should_not_run_for_triage_command_inside_fenced_code(self) -> None:
(False, "issue comment did not contain @codex /triage command"),
)
+ def test_triage_command_ignores_code_examples(self) -> None:
+ code_only = (
+ "~~~\n@codex /triage\n~~~",
+ "~~~~log\n@codex /triage\n~~~~",
+ "````\n@codex /triage\n```\n@codex /triage\n````",
+ "~~~\n```\n@codex /triage\n```\n~~~",
+ " @codex /triage",
+ "\t@codex /triage",
+ "Intro\n\n @codex /triage",
+ " ~~~\n @codex /triage\n ~~~",
+ "- ```\n @codex /triage\n ```",
+ "1. ~~~\n @codex /triage\n ~~~",
+ "- item\n\n @codex /triage",
+ "`@codex /triage`",
+ "\\@codex /triage",
+ "\n\n@codex /triage\n
\n ",
+ "\n\n ",
+ "\n```\n@codex /triage\n```\n ",
+ "\n\n@codex /triage\n
\n ",
+ "\n@codex /triage\n ",
+ "\n\n\n@codex /triage\n
\n ",
+ "\n\n\n@codex /triage\n\n ",
+ "\n\n ",
+ "\n\n ",
+ "\n- ```\n @codex /triage\n ```\n ",
+ "\n- - ~~~\n @codex /triage\n ~~~\n ",
+ "\n```\n@codex /triage example\n\n@codex /triage\n```\n ",
+ "\n- ~~~\n @codex /triage example\n\n @codex /triage\n ~~~\n ",
+ "\n```\n@codex /triage example\n\n```\n@codex /triage\n```\n ",
+ "\n```\n@codex /triage example\n\n@codex /triage example\n```\n@codex /triage\n ",
+ "\n- ~~~\n @codex /triage example\n\n ~~~\n@codex /triage\n~~~\n ",
+ "- - ```\n @codex /triage\n ```",
+ "`example\n@codex /triage\ncontinued`",
+ )
+ for comment in code_only:
+ with self.subTest(comment=comment):
+ self.assertFalse(prepare.comment_has_triage_command(comment, "codex"))
+
+ def test_triage_command_runs_after_code_examples(self) -> None:
+ for comment in (
+ "~~~\n@codex /triage example\n~~~\n@codex /triage real request",
+ "````\n@codex /triage example\n````\n@codex /triage",
+ " @codex /triage example\n@codex /triage",
+ "Please\n @codex /triage",
+ "- item\n @codex /triage",
+ "- item\n\n @codex /triage",
+ "- @codex /triage",
+ "1. @codex /triage",
+ "- ```\n @codex /triage example\n ```\n@codex /triage",
+ "\n@codex /triage\n ",
+ "\n\n@codex /triage example\n
\n@codex /triage\n ",
+ "\n```\n@codex /triage example\n```\n@codex /triage\n ",
+ "\n@codex /triage please now\n ",
+ "- - @codex /triage",
+ "1. - @codex /triage",
+ "@codex /triage\n`example\ncontinued`",
+ "`example\ncontinued`\n@codex /triage",
+ "prefix `example\ncontinued`\n@codex /triage",
+ "[link](https://e.test/`a`)\n`code\nend`\n@codex /triage",
+ "[link](https://e.test/`code end`)\n`code\nend`\n@codex /triage",
+ "\n\n\n@codex /triage example\n\n@codex /triage\n ",
+ "\n\n@codex /triage\n ",
+ "\n- ```\n @codex /triage example\n ```\n@codex /triage\n ",
+ "\n```\n@codex /triage example\n\n```\n@codex /triage example\n```\n@codex /triage\n ",
+ "\n```\n\n\n@codex /triage example\n```\n@codex /triage example\n```\n@codex /triage\n ",
+ ):
+ with self.subTest(comment=comment):
+ self.assertTrue(prepare.comment_has_triage_command(comment, "codex"))
+
+ def test_triage_command_tracks_script_and_style_across_html_blocks(self) -> None:
+ for tag in ("script", "style"):
+ example = f"\n<{tag}>\n@codex /triage example\n\n{tag}>"
+ with self.subTest(tag=tag, command_after=False):
+ self.assertFalse(prepare.comment_has_triage_command(f"{example}\n ", "codex"))
+ with self.subTest(tag=tag, command_after=True):
+ self.assertTrue(
+ prepare.comment_has_triage_command(
+ f"{example}\n\n@codex /triage\n", "codex"
+ )
+ )
+
+ def test_triage_command_tracks_html_comments_across_blocks(self) -> None:
+ self.assertFalse(
+ prepare.comment_has_triage_command("
\n\n@codex /triage\n
", "codex"
+ )
+ )
+ self.assertTrue(
+ prepare.comment_has_triage_command(
+ "\n\n\n@codex /triage\n
", "codex"
+ )
+ )
+ example_then_code = (
+ "\n \n"
+ "@codex /triage example\n\n"
+ )
+ self.assertFalse(prepare.comment_has_triage_command(f"{example_then_code}
", "codex"))
+ self.assertTrue(
+ prepare.comment_has_triage_command(f"{example_then_code}@codex /triage\n", "codex")
+ )
+
def test_should_not_run_for_pull_request_comment(self) -> None:
event = {
"action": "created",
diff --git a/.github/aicodingflow-tests/test_review_workflow_dispatch.py b/.github/aicodingflow-tests/test_review_workflow_dispatch.py
index 6e9e713..bbaa019 100644
--- a/.github/aicodingflow-tests/test_review_workflow_dispatch.py
+++ b/.github/aicodingflow-tests/test_review_workflow_dispatch.py
@@ -347,6 +347,17 @@ def test_triage_workflow_allows_regular_issue_authors_through_preflight(self) ->
data = workflow(".github/workflows/triage-issue.yml")
triggers = data[True]
+ triage_steps = steps(data, "generate")
+ parser_setup = next(step for step in triage_steps if step.get("name") == "set-up-python-for-markdown-parser")
+ parser_install = next(step for step in triage_steps if step.get("name") == "install-markdown-parser")
+ self.assertEqual(parser_setup["uses"], "actions/setup-python@v6")
+ self.assertIn("-r .github/scripts/requirements-spec-context.txt", parser_install["run"])
+ self.assertLess(triage_steps.index(parser_setup), triage_steps.index(parser_install))
+ self.assertLess(
+ triage_steps.index(parser_install),
+ next(index for index, step in enumerate(triage_steps) if step.get("name") == "prepare-triage-context"),
+ )
+
self.assertEqual(triggers["issues"]["types"], ["opened", "reopened"])
self.assertEqual(triggers["issue_comment"]["types"], ["created"])
diff --git a/.github/scripts/prepare_issue_triage_context.py b/.github/scripts/prepare_issue_triage_context.py
index 357a2a0..16860ac 100644
--- a/.github/scripts/prepare_issue_triage_context.py
+++ b/.github/scripts/prepare_issue_triage_context.py
@@ -5,7 +5,9 @@
import argparse
import os
+import re
from datetime import datetime, timedelta, timezone
+from html.parser import HTMLParser
from pathlib import Path
from typing import Any
@@ -27,6 +29,105 @@
NEEDS_INFO_LABEL = "needs-info"
+class _VisibleTriageHtml(HTMLParser):
+ HIDDEN_TAGS = {"code", "pre", "blockquote", "script", "style", "textarea"}
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.hidden_tags: list[str] = []
+ self.comment_open = False
+ self.fence_char = ""
+ self.fence_length = 0
+ self.text: list[str] = []
+
+ def track_comments(self, source: str) -> bool:
+ position = 0
+ closed = False
+ while position < len(source):
+ if self.comment_open:
+ end = source.find("-->", position)
+ if end < 0:
+ break
+ self.comment_open = False
+ closed = True
+ position = end + 3
+ else:
+ start = source.find("")
+ html.feed(source_line[: end + 3] if end >= 0 else source_line + "\n")
+ comment_closed = html.track_comments(source_line)
+ if comment_closed:
+ html.close()
+ comment_hidden_lines.append(was_in_comment)
+
+ visible_lines = [""]
+ safe_line_count = None
+ for inline in block.children or []:
+ if inline.type == "text":
+ parts = inline.content.split("\n")
+ visible = not html.hidden_tags
+ visible_lines[-1] += parts[0] if visible else "\0"
+ visible_lines.extend(parts[1:] if visible else ["\0"] * (len(parts) - 1))
+ elif inline.type in {"softbreak", "hardbreak"}:
+ visible_lines.append("")
+ elif inline.type == "html_inline":
+ html.feed(inline.content)
+ if not html.comment_open or "-->" in inline.content:
+ html.close()
+ visible_lines[-1] += "\0"
+ visible_lines.extend(["\0"] * inline.content.count("\n"))
+ elif inline.type == "code_inline":
+ newlines = inline.meta.get("source_newlines")
+ if newlines is None and safe_line_count is None:
+ safe_line_count = len(visible_lines)
+ visible_lines[-1] += "\0"
+ if newlines is not None:
+ visible_lines.extend(["\0"] * newlines)
+ else:
+ visible_lines[-1] += "\0"
+
+ line_count = min(len(visible_lines), len(paragraph_lines))
+ if len(visible_lines) != len(paragraph_lines):
+ line_count = min(line_count, safe_line_count or 0)
+ for source, visible, in_comment in zip(
+ paragraph_lines[:line_count], visible_lines[:line_count], comment_hidden_lines[:line_count]
+ ):
+ if in_comment:
+ continue
+ source = source.strip()
+ for _ in range(list_depth):
+ source = re.sub(r"^(?:[-+*]|[0-9]{1,9}[.)])\s+", "", source, count=1)
+ if (source == expected or source.startswith(f"{expected} ")) and (
+ visible.strip() == expected or visible.strip().startswith(f"{expected} ")
+ ):
+ return True
return False
diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml
index caa1f71..69cfe2c 100644
--- a/.github/workflows/triage-issue.yml
+++ b/.github/workflows/triage-issue.yml
@@ -67,6 +67,18 @@ jobs:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
+ - name: set-up-python-for-markdown-parser
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+
+ - name: install-markdown-parser
+ run: |
+ python3 -m venv "$RUNNER_TEMP/aicodingflow-triage-venv"
+ "$RUNNER_TEMP/aicodingflow-triage-venv/bin/python" -m pip install \
+ --disable-pip-version-check -r .github/scripts/requirements-spec-context.txt
+ echo "$RUNNER_TEMP/aicodingflow-triage-venv/bin" >> "$GITHUB_PATH"
+
- name: prepare-triage-context
id: issue
env: