From 9cd954e1588a5c48519c61e7e1101c2f129c2a09 Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Tue, 25 Aug 2026 19:39:39 +0700 Subject: [PATCH] fix(taskstoissues): scope issue dedup to the feature the tasks belong to Task IDs are local to a feature -- every tasks.md restarts at T001 -- but the dedup matched existing issues on the bare ID. So once feature 001-auth had an issue titled T001, running the command for 002-billing saw "T001 exists" and skipped it. The task was never created and nothing said so, which is a silent gap in exactly the multi-feature repos this command targets. The canonical title now carries the feature directory basename, and a task is skipped only when an existing issue matches both that identifier and the ID. The ID keeps its own word boundaries inside the prefixed title, so the \bT\d{3,}\b matching from #2968 is unchanged. Issues filed before the prefix existed carry a bare `T001: ...`; those are still recognised for their own feature, so upgrading does not re-create work that is already tracked. Closes #4271 --- templates/commands/taskstoissues.md | 7 ++- .../unit/test_taskstoissues_feature_scope.py | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_taskstoissues_feature_scope.py diff --git a/templates/commands/taskstoissues.md b/templates/commands/taskstoissues.md index 36c12316e5..599b31a2b4 100644 --- a/templates/commands/taskstoissues.md +++ b/templates/commands/taskstoissues.md @@ -64,9 +64,10 @@ git config --get remote.origin.url > [!CAUTION] > ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL -1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `__SPECKIT_COMMAND_CONVERGE__` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked. -1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: `, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`). - - **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`). +1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `__SPECKIT_COMMAND_CONVERGE__` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue **only if the title also carries this feature's identifier** (see below). Task IDs restart at `T001` in every feature's `tasks.md`, so an unscoped match means the first feature to reach the tracker permanently suppresses `T001` for every later feature -- a silent gap in exactly the multi-feature repos this command is for. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked. +1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `[] T001: `, where `` is the basename of FEATURE_DIR parsed in step 1 (the `NNN-name` spec directory, e.g. `002-billing`), followed by the ID written once and then the task description (for example, the line `- [ ] T001 Create project structure` in feature `002-billing` becomes the title `[002-billing] T001: Create project structure`). The ID keeps its own word boundaries, so the `T\d{3,}` matching above is unchanged by the prefix. + - **Skip** a task only when an existing issue matches **both** this feature's identifier and the task ID, and report it (for example, `[002-billing] T001 already has an issue, skipping`). A `T001` belonging to another feature is a different task and must not suppress this one. + - Issues created before this scoping exists carry a bare `T001: ...` title. Treat those as matching only when no `[]` prefix is present anywhere in the fetched set for that ID, so an upgrade does not re-create issues that are already tracked. - Only create issues for tasks that do not yet have a matching issue. > [!CAUTION] diff --git a/tests/unit/test_taskstoissues_feature_scope.py b/tests/unit/test_taskstoissues_feature_scope.py new file mode 100644 index 0000000000..29c6981fe5 --- /dev/null +++ b/tests/unit/test_taskstoissues_feature_scope.py @@ -0,0 +1,62 @@ +"""Issue dedup in `/speckit-taskstoissues` must be scoped to one feature. + +Task IDs are local to a feature: every `tasks.md` restarts at `T001`. Matching existing +issues by task ID alone means the first feature to reach the tracker permanently +suppresses `T001` for every later feature — the tasks are silently never created, which +is worse than the duplicates the matching was tightened to prevent (#4271). + +These assert the template still carries the scoping, so a later edit to that step cannot +quietly drop it again. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +TEMPLATE = PROJECT_ROOT / "templates" / "commands" / "taskstoissues.md" + + +@pytest.fixture(scope="module") +def template_text() -> str: + assert TEMPLATE.is_file(), f"missing command template: {TEMPLATE}" + return TEMPLATE.read_text(encoding="utf-8") + + +def _line_containing(text: str, needle: str) -> str: + matches = [line for line in text.splitlines() if needle in line] + assert matches, f"no instruction line contains {needle!r} any more" + return "\n".join(matches) + + +def test_the_canonical_title_carries_the_feature_identifier(template_text: str) -> None: + """Without the feature in the title there is nothing for the dedup to scope on.""" + title_rule = _line_containing(template_text, "canonical title") + assert re.search(r"`\[\]\s+T001:", title_rule), ( + "the canonical issue title no longer names the feature, so two features' T001 " + f"issues are indistinguishable:\n {title_rule.strip()}" + ) + assert "FEATURE_DIR" in title_rule, ( + "the title rule should say where comes from (the FEATURE_DIR parsed in " + f"step 1):\n {title_rule.strip()}" + ) + + +def test_the_skip_rule_requires_both_feature_and_task_id(template_text: str) -> None: + skip_rule = _line_containing(template_text, "**Skip**") + assert "both" in skip_rule.lower(), ( + "the skip rule must require the feature identity as well as the task ID, or a " + f"sibling feature's T001 suppresses this one:\n {skip_rule.strip()}" + ) + assert "feature" in skip_rule.lower(), skip_rule.strip() + + +def test_pre_existing_unscoped_issues_are_still_recognised(template_text: str) -> None: + """Upgrading must not re-create issues that were filed before the prefix existed.""" + assert re.search(r"before this scoping exists|bare `T001", template_text), ( + "the template no longer says what to do with issues created before the feature " + "prefix, so an upgrade would duplicate every already-tracked task" + )