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" + 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: