From ba0cbf284cce615483048fa0c15f422e6dab7e71 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Fri, 6 Mar 2026 22:20:19 +0100 Subject: [PATCH 1/3] refactor: remove backlog ownership from core cli --- CHANGELOG.md | 14 + README.md | 2 + docs/getting-started/installation.md | 4 +- .../tutorial-backlog-refine-ai-ide.md | 2 + .../tutorial-daily-standup-sprint-review.md | 4 +- docs/guides/agile-scrum-workflows.md | 1 + docs/guides/ai-ide-workflow.md | 2 +- docs/guides/devops-adapter-integration.md | 2 +- docs/index.md | 2 + modules/backlog-core/module-package.yaml | 33 - .../backlog-core/src/backlog_core/__init__.py | 7 - .../src/backlog_core/adapters/__init__.py | 6 - .../backlog_core/adapters/backlog_protocol.py | 51 - .../src/backlog_core/analyzers/__init__.py | 6 - .../src/backlog_core/analyzers/dependency.py | 173 -- .../src/backlog_core/commands/__init__.py | 38 - .../src/backlog_core/commands/add.py | 807 -------- .../src/backlog_core/commands/analyze_deps.py | 146 -- .../src/backlog_core/commands/delta.py | 166 -- .../src/backlog_core/commands/diff.py | 59 - .../src/backlog_core/commands/promote.py | 41 - .../backlog_core/commands/release_notes.py | 64 - .../src/backlog_core/commands/shared.py | 25 - .../src/backlog_core/commands/sync.py | 159 -- .../src/backlog_core/commands/verify.py | 103 - .../src/backlog_core/graph/__init__.py | 18 - .../src/backlog_core/graph/builder.py | 282 --- .../src/backlog_core/graph/config_schema.py | 77 - .../src/backlog_core/graph/models.py | 90 - modules/backlog-core/src/backlog_core/main.py | 60 - .../resources/backlog-templates/ado_safe.yaml | 14 - .../backlog-templates/ado_scrum.yaml | 15 - .../backlog-templates/github_custom.yaml | 22 - .../backlog-templates/github_projects.yaml | 16 - .../backlog-templates/jira_kanban.yaml | 13 - .../tests/unit/test_adapter_create_issue.py | 334 --- .../tests/unit/test_add_command.py | 1031 --------- .../tests/unit/test_backlog_protocol.py | 71 - .../tests/unit/test_command_order.py | 47 - .../tests/unit/test_provider_enrichment.py | 156 -- .../tests/unit/test_schema_extensions.py | 119 -- .../OWNERSHIP_MATRIX.md | 114 + .../TDD_EVIDENCE.md | 53 + .../specs/backlog-module-ownership/spec.md | 5 + .../backlog-module-ownership-cleanup/tasks.md | 30 +- pyproject.toml | 3 +- resources/prompts/specfact.backlog-add.md | 90 - resources/prompts/specfact.backlog-daily.md | 125 -- resources/prompts/specfact.backlog-refine.md | 557 ----- resources/prompts/specfact.sync-backlog.md | 557 ----- .../templates/backlog/defaults/defect_v1.yaml | 22 - .../backlog/defaults/enabler_v1.yaml | 21 - .../templates/backlog/defaults/spike_v1.yaml | 20 - .../backlog/defaults/user_story_v1.yaml | 19 - .../backlog/field_mappings/ado_agile.yaml | 24 - .../backlog/field_mappings/ado_default.yaml | 24 - .../backlog/field_mappings/ado_kanban.yaml | 26 - .../backlog/field_mappings/ado_safe.yaml | 29 - .../backlog/field_mappings/ado_scrum.yaml | 24 - .../frameworks/safe/safe_feature_v1.yaml | 26 - .../frameworks/scrum/user_story_v1.yaml | 23 - .../personas/developer/developer_task_v1.yaml | 28 - .../personas/product-owner/user_story_v1.yaml | 24 - .../backlog/providers/ado/work_item_v1.yaml | 21 - scripts/yaml-tools.sh | 12 +- setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- src/specfact_cli/backlog/__init__.py | 12 +- src/specfact_cli/backlog/adapters/__init__.py | 9 +- .../backlog/adapters/local_yaml_adapter.py | 177 -- src/specfact_cli/backlog/ai_refiner.py | 523 ----- src/specfact_cli/backlog/format_detector.py | 50 - src/specfact_cli/backlog/formats/__init__.py | 14 - src/specfact_cli/backlog/formats/base.py | 98 - .../backlog/formats/markdown_format.py | 130 -- .../backlog/formats/structured_format.py | 97 - src/specfact_cli/backlog/template_detector.py | 288 --- src/specfact_cli/commands/backlog_commands.py | 18 - src/specfact_cli/groups/__init__.py | 4 +- src/specfact_cli/groups/backlog_group.py | 63 - src/specfact_cli/groups/member_group.py | 60 + src/specfact_cli/registry/module_packages.py | 44 +- src/specfact_cli/utils/ide_setup.py | 4 - .../test_backlog_refine_limit_and_cancel.py | 214 -- .../backlog/test_backlog_refinement_e2e.py | 364 ---- .../backlog/test_additional_commands_e2e.py | 121 -- tests/integration/backlog/test_ado_e2e.py | 66 - .../test_backlog_filtering_integration.py | 278 --- .../test_backlog_refine_sync_chaining.py | 268 --- .../backlog/test_backlog_refinement_flow.py | 208 -- .../backlog/test_custom_field_mapping.py | 179 -- tests/integration/backlog/test_delta_e2e.py | 136 -- tests/integration/backlog/test_github_e2e.py | 66 - .../backlog/test_provider_enrichment_e2e.py | 131 -- tests/integration/backlog/test_sync_e2e.py | 89 - .../backlog/test_verify_readiness_e2e.py | 96 - ...test_command_package_runtime_validation.py | 25 +- tests/unit/backlog/test_ai_refiner.py | 358 ---- tests/unit/backlog/test_analyzers.py | 117 -- tests/unit/backlog/test_backlog_adapter.py | 259 --- tests/unit/backlog/test_backlog_format.py | 103 - tests/unit/backlog/test_builders.py | 134 -- tests/unit/backlog/test_format_detector.py | 66 - tests/unit/backlog/test_graph_models.py | 74 - tests/unit/backlog/test_local_yaml_adapter.py | 230 --- tests/unit/backlog/test_markdown_format.py | 90 - tests/unit/backlog/test_structured_format.py | 138 -- tests/unit/backlog/test_template_detector.py | 245 --- .../commands/test_backlog_ceremony_group.py | 48 - tests/unit/commands/test_backlog_commands.py | 1833 ----------------- tests/unit/commands/test_backlog_daily.py | 22 - tests/unit/commands/test_backlog_filtering.py | 428 ---- .../test_module_migration_compatibility.py | 1 - .../test_backlog_module_ownership_cleanup.py | 64 + tests/unit/utils/test_ide_setup.py | 17 +- 116 files changed, 395 insertions(+), 13829 deletions(-) delete mode 100644 modules/backlog-core/module-package.yaml delete mode 100644 modules/backlog-core/src/backlog_core/__init__.py delete mode 100644 modules/backlog-core/src/backlog_core/adapters/__init__.py delete mode 100644 modules/backlog-core/src/backlog_core/adapters/backlog_protocol.py delete mode 100644 modules/backlog-core/src/backlog_core/analyzers/__init__.py delete mode 100644 modules/backlog-core/src/backlog_core/analyzers/dependency.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/__init__.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/add.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/analyze_deps.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/delta.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/diff.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/promote.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/release_notes.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/shared.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/sync.py delete mode 100644 modules/backlog-core/src/backlog_core/commands/verify.py delete mode 100644 modules/backlog-core/src/backlog_core/graph/__init__.py delete mode 100644 modules/backlog-core/src/backlog_core/graph/builder.py delete mode 100644 modules/backlog-core/src/backlog_core/graph/config_schema.py delete mode 100644 modules/backlog-core/src/backlog_core/graph/models.py delete mode 100644 modules/backlog-core/src/backlog_core/main.py delete mode 100644 modules/backlog-core/src/backlog_core/resources/backlog-templates/ado_safe.yaml delete mode 100644 modules/backlog-core/src/backlog_core/resources/backlog-templates/ado_scrum.yaml delete mode 100644 modules/backlog-core/src/backlog_core/resources/backlog-templates/github_custom.yaml delete mode 100644 modules/backlog-core/src/backlog_core/resources/backlog-templates/github_projects.yaml delete mode 100644 modules/backlog-core/src/backlog_core/resources/backlog-templates/jira_kanban.yaml delete mode 100644 modules/backlog-core/tests/unit/test_adapter_create_issue.py delete mode 100644 modules/backlog-core/tests/unit/test_add_command.py delete mode 100644 modules/backlog-core/tests/unit/test_backlog_protocol.py delete mode 100644 modules/backlog-core/tests/unit/test_command_order.py delete mode 100644 modules/backlog-core/tests/unit/test_provider_enrichment.py delete mode 100644 modules/backlog-core/tests/unit/test_schema_extensions.py create mode 100644 openspec/changes/backlog-module-ownership-cleanup/OWNERSHIP_MATRIX.md create mode 100644 openspec/changes/backlog-module-ownership-cleanup/TDD_EVIDENCE.md delete mode 100644 resources/prompts/specfact.backlog-add.md delete mode 100644 resources/prompts/specfact.backlog-daily.md delete mode 100644 resources/prompts/specfact.backlog-refine.md delete mode 100644 resources/prompts/specfact.sync-backlog.md delete mode 100644 resources/templates/backlog/defaults/defect_v1.yaml delete mode 100644 resources/templates/backlog/defaults/enabler_v1.yaml delete mode 100644 resources/templates/backlog/defaults/spike_v1.yaml delete mode 100644 resources/templates/backlog/defaults/user_story_v1.yaml delete mode 100644 resources/templates/backlog/field_mappings/ado_agile.yaml delete mode 100644 resources/templates/backlog/field_mappings/ado_default.yaml delete mode 100644 resources/templates/backlog/field_mappings/ado_kanban.yaml delete mode 100644 resources/templates/backlog/field_mappings/ado_safe.yaml delete mode 100644 resources/templates/backlog/field_mappings/ado_scrum.yaml delete mode 100644 resources/templates/backlog/frameworks/safe/safe_feature_v1.yaml delete mode 100644 resources/templates/backlog/frameworks/scrum/user_story_v1.yaml delete mode 100644 resources/templates/backlog/personas/developer/developer_task_v1.yaml delete mode 100644 resources/templates/backlog/personas/product-owner/user_story_v1.yaml delete mode 100644 resources/templates/backlog/providers/ado/work_item_v1.yaml delete mode 100644 src/specfact_cli/backlog/adapters/local_yaml_adapter.py delete mode 100644 src/specfact_cli/backlog/ai_refiner.py delete mode 100644 src/specfact_cli/backlog/format_detector.py delete mode 100644 src/specfact_cli/backlog/formats/__init__.py delete mode 100644 src/specfact_cli/backlog/formats/base.py delete mode 100644 src/specfact_cli/backlog/formats/markdown_format.py delete mode 100644 src/specfact_cli/backlog/formats/structured_format.py delete mode 100644 src/specfact_cli/backlog/template_detector.py delete mode 100644 src/specfact_cli/commands/backlog_commands.py delete mode 100644 src/specfact_cli/groups/backlog_group.py create mode 100644 src/specfact_cli/groups/member_group.py delete mode 100644 tests/e2e/backlog/test_backlog_refine_limit_and_cancel.py delete mode 100644 tests/e2e/backlog/test_backlog_refinement_e2e.py delete mode 100644 tests/integration/backlog/test_additional_commands_e2e.py delete mode 100644 tests/integration/backlog/test_ado_e2e.py delete mode 100644 tests/integration/backlog/test_backlog_filtering_integration.py delete mode 100644 tests/integration/backlog/test_backlog_refine_sync_chaining.py delete mode 100644 tests/integration/backlog/test_backlog_refinement_flow.py delete mode 100644 tests/integration/backlog/test_custom_field_mapping.py delete mode 100644 tests/integration/backlog/test_delta_e2e.py delete mode 100644 tests/integration/backlog/test_github_e2e.py delete mode 100644 tests/integration/backlog/test_provider_enrichment_e2e.py delete mode 100644 tests/integration/backlog/test_sync_e2e.py delete mode 100644 tests/integration/backlog/test_verify_readiness_e2e.py delete mode 100644 tests/unit/backlog/test_ai_refiner.py delete mode 100644 tests/unit/backlog/test_analyzers.py delete mode 100644 tests/unit/backlog/test_backlog_adapter.py delete mode 100644 tests/unit/backlog/test_backlog_format.py delete mode 100644 tests/unit/backlog/test_builders.py delete mode 100644 tests/unit/backlog/test_format_detector.py delete mode 100644 tests/unit/backlog/test_graph_models.py delete mode 100644 tests/unit/backlog/test_local_yaml_adapter.py delete mode 100644 tests/unit/backlog/test_markdown_format.py delete mode 100644 tests/unit/backlog/test_structured_format.py delete mode 100644 tests/unit/backlog/test_template_detector.py delete mode 100644 tests/unit/commands/test_backlog_ceremony_group.py delete mode 100644 tests/unit/commands/test_backlog_commands.py delete mode 100644 tests/unit/commands/test_backlog_filtering.py create mode 100644 tests/unit/test_backlog_module_ownership_cleanup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 13bd6c9c..2aa3ac98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ All notable changes to this project will be documented in this file. **Important:** Changes need to be documented below this block as this is the header section. Each section should be separated by a horizontal rule. Newer changelog entries need to be added on top of prior ones to keep the history chronological with most recent changes first. +--- + +## [0.40.2] - 2026-03-06 + +### Changed + +- Finished the backlog ownership cleanup in core: built-in backlog command shims, bundled backlog prompts/templates, and the `backlog-core` package were removed so backlog functionality is owned by the marketplace module instead of `specfact-cli`. +- Replaced backlog-specific command-group wiring with generic member-group registration so installed modules provide `backlog` and `policy` surfaces without core overlap rules. + +### Fixed + +- Removed the root cause of duplicate backlog command registration at startup by eliminating the split core-plus-module backlog ownership model. +- Updated core validation and IDE prompt export expectations so backlog prompt assets are no longer treated as built-in core resources. + --- ## [0.40.1] - 2026-03-06 diff --git a/README.md b/README.md index d921d565..576f8118 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ As of `0.40.0`, flat root commands are removed. Use grouped commands: ### Backlog Bridge (60 seconds) SpecFact's USP is closing the drift gap between **backlog -> specs -> code**. +These commands require the backlog bundle to be installed first, for example via +`specfact init --profile backlog-team` or `specfact init --install backlog`. ```bash # 1) Initialize backlog config + field mapping diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index c4c9cfcd..4021581e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -191,7 +191,6 @@ uvx specfact-cli@latest import from-code my-project --repo . Fresh install exposes only core commands: - `specfact init` -- `specfact backlog auth` - `specfact module` - `specfact upgrade` @@ -203,6 +202,9 @@ Category groups appear after bundle installation: - `specfact spec ...` - `specfact govern ...` +Backlog authentication commands such as `specfact backlog auth ...` are provided by the +installed backlog bundle, not by the permanent core command surface. + Profile outcomes: | Profile | Installed bundles | Available groups | diff --git a/docs/getting-started/tutorial-backlog-refine-ai-ide.md b/docs/getting-started/tutorial-backlog-refine-ai-ide.md index 9b379ce2..a267335f 100644 --- a/docs/getting-started/tutorial-backlog-refine-ai-ide.md +++ b/docs/getting-started/tutorial-backlog-refine-ai-ide.md @@ -76,6 +76,8 @@ In Cursor, VS Code, or your IDE: 2. Pass the same arguments you would use in the CLI, for example: - `/specfact.backlog-refine --adapter github --repo-owner OWNER --repo-name NAME --labels feature --limit 5` +These slash prompts are provided by the installed backlog bundle, not by the permanent core CLI package. + The AI will use the **SpecFact Backlog Refinement** prompt, which includes: - Template-driven refinement (user story, defect, spike, enabler) diff --git a/docs/getting-started/tutorial-daily-standup-sprint-review.md b/docs/getting-started/tutorial-daily-standup-sprint-review.md index c2d7b002..99ef06e0 100644 --- a/docs/getting-started/tutorial-daily-standup-sprint-review.md +++ b/docs/getting-started/tutorial-daily-standup-sprint-review.md @@ -35,6 +35,7 @@ Preferred command path is `specfact backlog ceremony standup ...`. The legacy `s The prompt content is always **normalized to Markdown-only text** (no raw HTML tags or HTML entities) so ADO-style HTML descriptions/comments and GitHub/Markdown content render consistently. - Use the **`specfact.backlog-daily`** (or `specfact.daily`) slash prompt for interactive walkthrough with the DevOps team story-by-story (focus, issues, open questions, discussion notes as comments) +- The daily standup slash prompt is provided by the installed backlog bundle rather than the permanent core CLI package - Filter by **`--assignee`**, **`--sprint`** / **`--iteration`**, **`--search`**, **`--release`**, **`--id`**, **`--first-issues`** / **`--last-issues`**, **`--blockers-first`**, and optional **`--suggest-next`** --- @@ -162,7 +163,8 @@ The output includes an instruction to generate a standup summary, the applied fi state, sprint, assignee, limit), and the same per-item data as `--copilot-export`. With `--comments`/`--annotations`, the prompt includes normalized descriptions and comment annotations when supported. Use it with the **`specfact.backlog-daily`** slash prompt for interactive team walkthrough -(story-by-story, current focus, issues/open questions, discussion notes as comments). +(story-by-story, current focus, issues/open questions, discussion notes as comments). The slash prompt +itself is provided by the installed backlog bundle. --- diff --git a/docs/guides/agile-scrum-workflows.md b/docs/guides/agile-scrum-workflows.md index 7577acc7..3af4a041 100644 --- a/docs/guides/agile-scrum-workflows.md +++ b/docs/guides/agile-scrum-workflows.md @@ -176,6 +176,7 @@ scope: **state=open**, **limit=20**; configure via `SPECFACT_STANDUP_*` or `.spe `--copilot-export `, `--summarize`, `--summarize-to `, `--comments`/`--annotations`, and optional `--first-comments`/`--last-comments` plus `--first-issues`/`--last-issues` as well as global filters `--search`, `--release`, and `--id` to narrow scope consistently with backlog ceremony refinement. +The slash prompt itself is provided by the installed backlog bundle rather than the permanent core CLI package. See [Tutorial: Daily Standup and Sprint Review](../getting-started/tutorial-daily-standup-sprint-review.md) for the full walkthrough. diff --git a/docs/guides/ai-ide-workflow.md b/docs/guides/ai-ide-workflow.md index 3c1d2cfc..4f9702e0 100644 --- a/docs/guides/ai-ide-workflow.md +++ b/docs/guides/ai-ide-workflow.md @@ -76,7 +76,7 @@ Once initialized, the following slash commands are available in your IDE: |---------------|---------|------------------------| | `/specfact.compare` | Compare plans | `specfact project plan compare` | | `/specfact.validate` | Validation suite | `specfact code repro` | -| `/specfact.backlog-refine` | Backlog refinement (AI IDE interactive loop) | `specfact backlog refine github \| ado` | +| `/specfact.backlog-refine` | Backlog refinement (AI IDE interactive loop, provided by the backlog bundle) | `specfact backlog refine github \| ado` | For an end-to-end tutorial on backlog refine with your AI IDE (story quality, underspecification, DoR, custom templates), see **[Tutorial: Backlog Refine with AI IDE](../getting-started/tutorial-backlog-refine-ai-ide.md)**. diff --git a/docs/guides/devops-adapter-integration.md b/docs/guides/devops-adapter-integration.md index 01bd38e9..e3f160e4 100644 --- a/docs/guides/devops-adapter-integration.md +++ b/docs/guides/devops-adapter-integration.md @@ -61,7 +61,7 @@ SpecFact CLI supports **bidirectional synchronization** between OpenSpec change (instruction + filter context + standup data) for slash command or Copilot to generate a standup summary. **Slash prompt** `specfact.backlog-daily` (or `specfact.daily`): use with IDE/Copilot for interactive team walkthrough story-by-story (current focus, issues/open questions, discussion notes as comments); - prompt file at `resources/prompts/specfact.backlog-daily.md`. **Sprint goal** is stored in your + the prompt is provided by the installed backlog bundle rather than the permanent core package. **Sprint goal** is stored in your board/sprint settings and is not displayed or edited by the CLI. - **Content Sanitization**: Protect internal information when syncing to public repositories - **Separate Repository Support**: Handle cases where OpenSpec proposals and source code are in different repositories diff --git a/docs/index.md b/docs/index.md index 9291ec44..17a608ab 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,8 @@ Recommended command entrypoints: ### Backlog Bridge in 60 Seconds SpecFact closes the drift gap between **backlog -> specs -> code**. +These commands require the backlog bundle to be installed first, for example via +`specfact init --profile backlog-team` or `specfact init --install backlog`. ```bash # 1) Initialize backlog config + field mapping diff --git a/modules/backlog-core/module-package.yaml b/modules/backlog-core/module-package.yaml deleted file mode 100644 index 665131ca..00000000 --- a/modules/backlog-core/module-package.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: backlog-core -version: 0.1.8 -commands: - - backlog -category: backlog -bundle: specfact-backlog -bundle_group_command: backlog -bundle_sub_command: core -command_help: - backlog: Backlog dependency analysis, delta workflows, and release readiness -pip_dependencies: [] -module_dependencies: [] -core_compatibility: '>=0.40.0,<1.0.0' -tier: community -schema_extensions: - project_bundle: - backlog_core.backlog_graph: - type: BacklogGraph | None - description: Dependency graph for backlog analysis - project_metadata: - backlog_core.backlog_config: - type: dict[str, Any] | None - description: Backlog provider and template configuration -publisher: - name: nold-ai - url: https://github.com/nold-ai/specfact-cli-modules - email: hello@noldai.com -integrity: - checksum: sha256:dd2f9265e6881c3997943b34af0fc68e33deeada07252f034905b180a53784cc - signature: j0B318RU573tsr9xlh7Gtf6xyYWnGjv8cXTPQta6XHC3WM22XcOFJ+77Ul2UcUK6F84psMEhQvHwI/b/7YwgDQ== -dependencies: [] -description: Provide advanced backlog analysis and readiness capabilities. -license: Apache-2.0 diff --git a/modules/backlog-core/src/backlog_core/__init__.py b/modules/backlog-core/src/backlog_core/__init__.py deleted file mode 100644 index be033faa..00000000 --- a/modules/backlog-core/src/backlog_core/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Backlog core module package entrypoints.""" - -from .commands import commands_interface -from .main import app, backlog_app - - -__all__ = ["app", "backlog_app", "commands_interface"] diff --git a/modules/backlog-core/src/backlog_core/adapters/__init__.py b/modules/backlog-core/src/backlog_core/adapters/__init__.py deleted file mode 100644 index a011641f..00000000 --- a/modules/backlog-core/src/backlog_core/adapters/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Backlog-core adapter protocol helpers.""" - -from .backlog_protocol import BacklogGraphProtocol, require_backlog_graph_protocol - - -__all__ = ["BacklogGraphProtocol", "require_backlog_graph_protocol"] diff --git a/modules/backlog-core/src/backlog_core/adapters/backlog_protocol.py b/modules/backlog-core/src/backlog_core/adapters/backlog_protocol.py deleted file mode 100644 index 0b321515..00000000 --- a/modules/backlog-core/src/backlog_core/adapters/backlog_protocol.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Bridge protocol for backlog graph-capable adapters.""" - -from __future__ import annotations - -from typing import Any, Protocol, runtime_checkable - -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.registry.bridge_registry import BRIDGE_PROTOCOL_REGISTRY - - -@runtime_checkable -class BacklogGraphProtocol(Protocol): - """Protocol for bulk issue and relationship retrieval for graph analysis.""" - - @beartype - @require(lambda project_id: project_id.strip() != "", "project_id must be non-empty") - @ensure(lambda result: isinstance(result, list), "fetch_all_issues must return list") - def fetch_all_issues(self, project_id: str, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - """Fetch all backlog issues/work items for a project.""" - - @beartype - @require(lambda project_id: project_id.strip() != "", "project_id must be non-empty") - @ensure(lambda result: isinstance(result, list), "fetch_relationships must return list") - def fetch_relationships(self, project_id: str) -> list[dict[str, Any]]: - """Fetch all issue/work-item relationships for a project.""" - - @beartype - @require(lambda project_id: project_id.strip() != "", "project_id must be non-empty") - @require(lambda payload: isinstance(payload, dict), "payload must be dict") - @ensure(lambda result: isinstance(result, dict), "create_issue must return dict") - def create_issue(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: - """Create a provider issue/work item and return id/key/url metadata.""" - - -@beartype -@require(lambda adapter: adapter is not None, "adapter must be provided") -@ensure(lambda result: isinstance(result, BacklogGraphProtocol), "adapter must satisfy BacklogGraphProtocol") -def require_backlog_graph_protocol(adapter: Any) -> BacklogGraphProtocol: - """Validate adapter protocol support and return typed protocol view.""" - if not isinstance(adapter, BacklogGraphProtocol): - msg = ( - f"Adapter '{type(adapter).__name__}' does not support BacklogGraphProtocol. " - "Expected methods: fetch_all_issues(project_id, filters), fetch_relationships(project_id), create_issue(project_id, payload)." - ) - raise TypeError(msg) - return adapter - - -BRIDGE_PROTOCOL_REGISTRY.register_protocol("backlog_graph", BacklogGraphProtocol) diff --git a/modules/backlog-core/src/backlog_core/analyzers/__init__.py b/modules/backlog-core/src/backlog_core/analyzers/__init__.py deleted file mode 100644 index d5f4e03d..00000000 --- a/modules/backlog-core/src/backlog_core/analyzers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Backlog analyzers package.""" - -from .dependency import DependencyAnalyzer - - -__all__ = ["DependencyAnalyzer"] diff --git a/modules/backlog-core/src/backlog_core/analyzers/dependency.py b/modules/backlog-core/src/backlog_core/analyzers/dependency.py deleted file mode 100644 index 00d2dc76..00000000 --- a/modules/backlog-core/src/backlog_core/analyzers/dependency.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Dependency analyzer utilities for backlog graphs.""" - -from __future__ import annotations - -import sys -from collections import defaultdict -from typing import Any - -from beartype import beartype -from icontract import ensure, require - -from backlog_core.adapters.backlog_protocol import BacklogGraphProtocol -from backlog_core.graph.models import BacklogGraph, DependencyType - - -@beartype -class DependencyAnalyzer: - """Analyze dependency relationships for a backlog graph.""" - - @beartype - @require(lambda graph: len(graph.items) >= 0, "Graph must be initialized") - def __init__(self, graph: BacklogGraph) -> None: - self.graph = graph - self._adjacency: dict[str, list[str]] = defaultdict(list) - self._reverse_adjacency: dict[str, list[str]] = defaultdict(list) - self._dependency_types: dict[tuple[str, str], DependencyType] = {} - - for dep in graph.dependencies: - self._adjacency[dep.source_id].append(dep.target_id) - self._reverse_adjacency[dep.target_id].append(dep.source_id) - self._dependency_types[(dep.source_id, dep.target_id)] = dep.type - - @staticmethod - @beartype - @require(lambda adapter: adapter is not None, "adapter must be provided") - @ensure(lambda result: result is None, "Must return None") - def validate_adapter_protocol(adapter: Any) -> None: - """Fail fast when an adapter does not support backlog graph bulk fetch methods.""" - if not isinstance(adapter, BacklogGraphProtocol): - msg = ( - f"Adapter '{type(adapter).__name__}' does not support BacklogGraphProtocol; " - "required methods: fetch_all_issues(project_id, filters), fetch_relationships(project_id)." - ) - raise TypeError(msg) - - @beartype - @ensure(lambda result: isinstance(result, dict), "Transitive closure must be returned as dict") - def compute_transitive_closure(self) -> dict[str, list[str]]: - closure: dict[str, list[str]] = {} - for item_id in self.graph.items: - seen: set[str] = set() - self._traverse_dfs(item_id, seen) - seen.discard(item_id) - if seen: - closure[item_id] = sorted(seen) - return closure - - def _traverse_dfs(self, item_id: str, seen: set[str]) -> None: - for neighbor in self._adjacency.get(item_id, []): - if neighbor in seen: - continue - seen.add(neighbor) - self._traverse_dfs(neighbor, seen) - - @beartype - @ensure(lambda result: isinstance(result, list), "Cycle detection must return list") - def detect_cycles(self) -> list[list[str]]: - visited: set[str] = set() - recursion_stack: set[str] = set() - traversal_path: list[str] = [] - cycles: list[list[str]] = [] - - def visit(node: str) -> None: - visited.add(node) - recursion_stack.add(node) - traversal_path.append(node) - - for neighbor in self._adjacency.get(node, []): - if neighbor not in visited: - visit(neighbor) - elif neighbor in recursion_stack: - start = traversal_path.index(neighbor) - cycles.append([*traversal_path[start:], neighbor]) - - traversal_path.pop() - recursion_stack.remove(node) - - for node in self.graph.items: - if node not in visited: - visit(node) - - return cycles - - @beartype - @ensure(lambda result: isinstance(result, list), "Critical path must be returned as list") - def critical_path(self) -> list[str]: - # Beartype/icontract wrappers add call frames; allow ample headroom for deep chains. - required_limit = max(10000, len(self.graph.items) * 20) - if sys.getrecursionlimit() < required_limit: - sys.setrecursionlimit(required_limit) - - longest: list[str] = [] - memo: dict[str, list[str]] = {} - for node in self.graph.items: - candidate = self._longest_path_from(node, set(), memo) - if len(candidate) > len(longest): - longest = candidate - return longest - - def _longest_path_from(self, node: str, visiting: set[str], memo: dict[str, list[str]]) -> list[str]: - if node in memo: - return memo[node] - if node in visiting: - return [node] - - visiting.add(node) - best_tail: list[str] = [] - for neighbor in self._adjacency.get(node, []): - candidate = self._longest_path_from(neighbor, visiting, memo) - if len(candidate) > len(best_tail): - best_tail = candidate - visiting.remove(node) - path = [node, *best_tail] - memo[node] = path - return path - - @beartype - @require(lambda item_id: item_id.strip() != "", "item_id must be non-empty") - @ensure(lambda result: isinstance(result, dict), "Impact analysis result must be a dict") - def impact_analysis(self, item_id: str) -> dict[str, Any]: - direct_dependents = sorted(set(self._reverse_adjacency.get(item_id, []))) - - transitive_dependents: set[str] = set() - stack = list(direct_dependents) - while stack: - node = stack.pop() - if node in transitive_dependents: - continue - transitive_dependents.add(node) - stack.extend(self._reverse_adjacency.get(node, [])) - - blockers = sorted( - target - for (source, target), dep_type in self._dependency_types.items() - if source == item_id and dep_type == DependencyType.BLOCKS - ) - - return { - "direct_dependents": direct_dependents, - "transitive_dependents": sorted(transitive_dependents), - "blockers": blockers, - "estimated_impact_count": len(transitive_dependents), - } - - @beartype - @ensure(lambda result: isinstance(result, dict), "Coverage analysis result must be a dict") - def coverage_analysis(self) -> dict[str, Any]: - total_items = len(self.graph.items) - properly_typed = sum(1 for item in self.graph.items.values() if item.effective_type().value != "custom") - items_with_dependencies = {dep.source_id for dep in self.graph.dependencies} | { - dep.target_id for dep in self.graph.dependencies - } - cycles = self.detect_cycles() - - properly_typed_pct = (properly_typed / total_items * 100.0) if total_items else 0.0 - return { - "total_items": total_items, - "properly_typed": properly_typed, - "properly_typed_pct": round(properly_typed_pct, 2), - "with_dependencies": len(items_with_dependencies), - "orphan_count": len(self.graph.orphans), - "cycle_count": len(cycles), - } diff --git a/modules/backlog-core/src/backlog_core/commands/__init__.py b/modules/backlog-core/src/backlog_core/commands/__init__.py deleted file mode 100644 index 054d093f..00000000 --- a/modules/backlog-core/src/backlog_core/commands/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Backlog-core command entrypoints.""" - -from specfact_cli.contracts.module_interface import ModuleIOContract -from specfact_cli.modules import module_io_shim - -from .add import add -from .analyze_deps import analyze_deps, trace_impact -from .diff import diff -from .promote import promote -from .release_notes import generate_release_notes -from .sync import BacklogGraphToPlanBundle, compute_delta, sync -from .verify import verify_readiness - - -_MODULE_IO_CONTRACT = ModuleIOContract -import_to_bundle = module_io_shim.import_to_bundle -export_from_bundle = module_io_shim.export_from_bundle -sync_with_bundle = module_io_shim.sync_with_bundle -validate_bundle = module_io_shim.validate_bundle -commands_interface = module_io_shim - -__all__ = [ - "BacklogGraphToPlanBundle", - "add", - "analyze_deps", - "commands_interface", - "compute_delta", - "diff", - "export_from_bundle", - "generate_release_notes", - "import_to_bundle", - "promote", - "sync", - "sync_with_bundle", - "trace_impact", - "validate_bundle", - "verify_readiness", -] diff --git a/modules/backlog-core/src/backlog_core/commands/add.py b/modules/backlog-core/src/backlog_core/commands/add.py deleted file mode 100644 index add90eee..00000000 --- a/modules/backlog-core/src/backlog_core/commands/add.py +++ /dev/null @@ -1,807 +0,0 @@ -"""Backlog add command.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated, Any - -import requests -import typer -import yaml -from beartype import beartype -from icontract import require - -from backlog_core.adapters.backlog_protocol import require_backlog_graph_protocol -from backlog_core.graph.builder import BacklogGraphBuilder -from backlog_core.graph.config_schema import load_backlog_config_from_backlog_file, load_backlog_config_from_spec -from specfact_cli.adapters.registry import AdapterRegistry -from specfact_cli.models.dor_config import DefinitionOfReady -from specfact_cli.utils.prompts import print_error, print_info, print_success, print_warning, prompt_text - - -DEFAULT_CREATION_HIERARCHY: dict[str, list[str]] = { - "epic": [], - "feature": ["epic"], - "story": ["feature", "epic"], - "task": ["story", "feature"], - "bug": ["story", "feature", "epic"], - "spike": ["feature", "epic"], - "custom": ["epic", "feature", "story"], -} - -STORY_LIKE_TYPES: set[str] = {"story", "feature", "task", "bug"} - -DEFAULT_CUSTOM_MAPPING_FILES: dict[str, Path] = { - "ado": Path(".specfact/templates/backlog/field_mappings/ado_custom.yaml"), - "github": Path(".specfact/templates/backlog/field_mappings/github_custom.yaml"), -} - - -@beartype -def _prompt_multiline_text(field_label: str, end_marker: str) -> str: - """Read multiline text until a sentinel marker line is entered.""" - marker = end_marker.strip() or "::END::" - print_info(f"{field_label} (multiline). End input with '{marker}' on a new line.") - lines: list[str] = [] - while True: - try: - line = input() - except EOFError: - break - if line.strip() == marker: - break - lines.append(line) - return "\n".join(lines).strip() - - -@beartype -def _select_with_fallback(message: str, choices: list[str], default: str | None = None) -> str: - """Use questionary select when available, otherwise plain text prompt.""" - normalized = [choice for choice in choices if choice] - if not normalized: - return (default or "").strip() - - try: - import questionary # type: ignore[reportMissingImports] - - selected = questionary.select(message, choices=normalized, default=default).ask() - if isinstance(selected, str) and selected.strip(): - return selected.strip() - except Exception: - # If questionary is unavailable or fails, continue with plain-text prompt fallback. - pass - - print_info(f"{message}: {', '.join(normalized)}") - fallback_default = default if default in normalized else normalized[0] - return prompt_text(message, default=fallback_default) - - -@beartype -def _interactive_sprint_selection(adapter_name: str, adapter_instance: Any, project_id: str) -> str | None: - """Prompt for sprint/iteration selection (provider-aware).""" - adapter_lower = adapter_name.strip().lower() - - if adapter_lower != "ado": - raw = prompt_text("Sprint/iteration (optional)", default="", required=False).strip() - return raw or None - - current_iteration: str | None = None - list_iterations: list[str] = [] - - restore_org = getattr(adapter_instance, "org", None) - restore_project = getattr(adapter_instance, "project", None) - resolver = getattr(adapter_instance, "_resolve_graph_project_context", None) - if callable(resolver): - try: - resolved_org, resolved_project = resolver(project_id) - if hasattr(adapter_instance, "org"): - adapter_instance.org = resolved_org - if hasattr(adapter_instance, "project"): - adapter_instance.project = resolved_project - except Exception: - # Best-effort org/project resolution only; keep existing context on any failure. - pass - - get_current = getattr(adapter_instance, "_get_current_iteration", None) - if callable(get_current): - try: - resolved = get_current() - if isinstance(resolved, str) and resolved.strip(): - current_iteration = resolved.strip() - except Exception: - current_iteration = None - - get_list = getattr(adapter_instance, "_list_available_iterations", None) - if callable(get_list): - try: - candidates = get_list() - if isinstance(candidates, list): - list_iterations = [str(item).strip() for item in candidates if str(item).strip()] - except Exception: - list_iterations = [] - - if hasattr(adapter_instance, "org"): - adapter_instance.org = restore_org - if hasattr(adapter_instance, "project"): - adapter_instance.project = restore_project - - options = ["(skip sprint/iteration)"] - if current_iteration: - options.append(f"current: {current_iteration}") - options.extend([iteration for iteration in list_iterations if iteration != current_iteration]) - options.append("manual entry") - - default = f"current: {current_iteration}" if current_iteration else "manual entry" - selected = _select_with_fallback("Select sprint/iteration", options, default=default) - - if selected == "(skip sprint/iteration)": - return None - if selected.startswith("current: "): - return selected.removeprefix("current: ").strip() or None - if selected == "manual entry": - manual = prompt_text("Enter sprint/iteration path", default="", required=False).strip() - return manual or None - return selected.strip() or None - - -@beartype -@require(lambda value: isinstance(value, str), "value must be a string") -def _normalize_type(value: str) -> str: - return value.strip().lower().replace("_", " ").replace("-", " ") - - -@beartype -def _resolve_default_template(adapter_name: str, template: str | None) -> str: - if template and template.strip(): - return template.strip() - if adapter_name.strip().lower() == "ado": - return "ado_scrum" - return "github_projects" - - -@beartype -def _extract_item_type(item: Any) -> str: - """Best-effort normalized item type from graph item and raw payload.""" - value = getattr(item, "type", None) - enum_value = getattr(value, "value", None) - if isinstance(enum_value, str) and enum_value.strip(): - return _normalize_type(enum_value) - if isinstance(value, str) and value.strip(): - normalized = _normalize_type(value) - if normalized.startswith("itemtype."): - normalized = normalized.split(".", 1)[1] - if normalized: - return normalized - - inferred = getattr(item, "inferred_type", None) - inferred_value = getattr(inferred, "value", None) - if isinstance(inferred_value, str) and inferred_value.strip(): - return _normalize_type(inferred_value) - - raw_data = getattr(item, "raw_data", {}) - if isinstance(raw_data, dict): - fields = raw_data.get("fields") if isinstance(raw_data.get("fields"), dict) else {} - candidates = [ - raw_data.get("type"), - raw_data.get("work_item_type"), - fields.get("System.WorkItemType") if isinstance(fields, dict) else None, - raw_data.get("issue_type"), - ] - for candidate in candidates: - if isinstance(candidate, str) and candidate.strip(): - normalized = _normalize_type(candidate) - aliases = { - "user story": "story", - "product backlog item": "story", - "pb i": "story", - } - return aliases.get(normalized, normalized) - - return "custom" - - -@beartype -def _load_custom_config(custom_config: Path | None) -> dict[str, Any]: - if custom_config is None: - return {} - if not custom_config.exists(): - raise ValueError(f"Custom config file not found: {custom_config}") - loaded = yaml.safe_load(custom_config.read_text(encoding="utf-8")) - return loaded if isinstance(loaded, dict) else {} - - -@beartype -def _resolve_custom_config_path(adapter_name: str, custom_config: Path | None) -> Path | None: - """Resolve custom mapping file path with adapter-specific default fallback.""" - if custom_config is not None: - return custom_config - candidate = DEFAULT_CUSTOM_MAPPING_FILES.get(adapter_name.strip().lower()) - if candidate is not None and candidate.exists(): - return candidate - return None - - -@beartype -def _load_template_config(template: str) -> dict[str, Any]: - module_root = Path(__file__).resolve().parents[1] - template_file = module_root / "resources" / "backlog-templates" / f"{template}.yaml" - shared_template_file = ( - Path(__file__).resolve().parents[5] - / "src" - / "specfact_cli" - / "resources" - / "backlog-templates" - / f"{template}.yaml" - ) - - for candidate in (template_file, shared_template_file): - if candidate.exists(): - loaded = yaml.safe_load(candidate.read_text(encoding="utf-8")) - if isinstance(loaded, dict): - return loaded - return {} - - -@beartype -def _load_provider_settings(repo_path: Path, adapter_name: str) -> dict[str, Any]: - backlog_cfg = load_backlog_config_from_backlog_file(repo_path / ".specfact" / "backlog-config.yaml") - spec_config = backlog_cfg or load_backlog_config_from_spec(repo_path / ".specfact" / "spec.yaml") - if spec_config is None: - return {} - provider = spec_config.providers.get(adapter_name.strip().lower()) - if provider is None or not isinstance(provider.settings, dict): - return {} - return dict(provider.settings) - - -@beartype -def _load_field_mapping_payload(mapping_path: Path | None) -> dict[str, Any]: - if mapping_path is None or not mapping_path.exists(): - return {} - loaded = yaml.safe_load(mapping_path.read_text(encoding="utf-8")) - return loaded if isinstance(loaded, dict) else {} - - -@beartype -def _resolve_saved_mapping_path(repo_path: Path, configured_path: str | None, adapter_name: str) -> Path | None: - raw_path = (configured_path or "").strip() - if raw_path: - candidate = Path(raw_path) - return candidate if candidate.is_absolute() else repo_path / candidate - default_candidate = DEFAULT_CUSTOM_MAPPING_FILES.get(adapter_name.strip().lower()) - if default_candidate is None: - return None - return repo_path / default_candidate if not default_candidate.is_absolute() else default_candidate - - -@beartype -def _parse_custom_field_entries(entries: list[str] | None) -> dict[str, str]: - parsed: dict[str, str] = {} - for raw_entry in entries or []: - entry = raw_entry.strip() - if not entry or "=" not in entry: - raise ValueError("custom-field entries must use '=' format") - raw_name, raw_value = entry.split("=", 1) - name = raw_name.strip() - value = raw_value.strip() - if not name or not value: - raise ValueError("custom-field entries must use '=' format") - parsed[name] = value - return parsed - - -@beartype -def _resolve_ado_custom_fields( - repo_path: Path, - issue_type: str, - custom_fields: list[str] | None, -) -> dict[str, Any] | None: - settings = _load_provider_settings(repo_path, "ado") - if not settings and not custom_fields: - return None - - mapping_path = _resolve_saved_mapping_path( - repo_path, - str(settings.get("field_mapping_file") or "").strip() or None, - "ado", - ) - mapping_payload = _load_field_mapping_payload(mapping_path) - field_mappings = mapping_payload.get("field_mappings") - provider_to_canonical = field_mappings if isinstance(field_mappings, dict) else {} - - selected_work_item_type = str(settings.get("selected_work_item_type") or "").strip() - if not selected_work_item_type: - selected_work_item_type = "User Story" if issue_type == "story" else issue_type.replace("_", " ").title() - - required_by_type = settings.get("required_fields_by_work_item_type") - required_refs = [] - if isinstance(required_by_type, dict): - selected_required = required_by_type.get(selected_work_item_type) - if isinstance(selected_required, list): - required_refs = [str(item).strip() for item in selected_required if str(item).strip()] - - allowed_by_type = settings.get("allowed_values_by_work_item_type") - allowed_values: dict[str, list[str]] = {} - if isinstance(allowed_by_type, dict): - selected_allowed = allowed_by_type.get(selected_work_item_type) - if isinstance(selected_allowed, dict): - for field_name, raw_values in selected_allowed.items(): - if isinstance(raw_values, list): - allowed_values[str(field_name).strip()] = [ - str(item).strip() for item in raw_values if str(item).strip() - ] - - alias_to_provider: dict[str, str] = {} - for provider_field, canonical_name in provider_to_canonical.items(): - provider_key = str(provider_field).strip() - canonical_key = str(canonical_name).strip() - if provider_key: - alias_to_provider[provider_key.lower()] = provider_key - if canonical_key: - alias_to_provider[canonical_key.lower()] = provider_key - for provider_field in required_refs: - alias_to_provider.setdefault(provider_field.lower(), provider_field) - - parsed_entries = _parse_custom_field_entries(custom_fields) - resolved_fields: dict[str, Any] = {} - for raw_name, value in parsed_entries.items(): - provider_key = alias_to_provider.get(raw_name.strip().lower()) - if provider_key is None: - raise ValueError(f"unknown custom field '{raw_name}'") - resolved_fields[provider_key] = value - - for required_ref in required_refs: - if required_ref not in resolved_fields: - canonical = str(provider_to_canonical.get(required_ref) or "").strip() - label = canonical or required_ref - raise ValueError(f"missing required custom field '{label}'") - - for provider_key, value in resolved_fields.items(): - allowed = allowed_values.get(provider_key) or [] - if allowed and value not in allowed: - allowed_text = ", ".join(allowed) - canonical = str(provider_to_canonical.get(provider_key) or "").strip() - label = canonical or provider_key - raise ValueError(f"invalid value for custom field '{label}'. Allowed values: {allowed_text}") - - return {"fields": resolved_fields} if resolved_fields else None - - -@beartype -def _derive_creation_hierarchy(template_payload: dict[str, Any], custom_config: dict[str, Any]) -> dict[str, list[str]]: - custom_hierarchy = custom_config.get("creation_hierarchy") - if isinstance(custom_hierarchy, dict): - return { - _normalize_type(str(child)): [_normalize_type(str(parent)) for parent in parents] - for child, parents in custom_hierarchy.items() - if isinstance(parents, list) - } - - template_hierarchy = template_payload.get("creation_hierarchy") - if isinstance(template_hierarchy, dict): - return { - _normalize_type(str(child)): [_normalize_type(str(parent)) for parent in parents] - for child, parents in template_hierarchy.items() - if isinstance(parents, list) - } - - return DEFAULT_CREATION_HIERARCHY - - -@beartype -def _resolve_provider_fields_for_create( - adapter_name: str, - template_payload: dict[str, Any], - custom_config: dict[str, Any], - repo_path: Path, -) -> dict[str, Any] | None: - """Resolve provider-specific create payload fields from template/custom config.""" - if adapter_name.strip().lower() != "github": - return None - - def _extract_github_project_v2(source: dict[str, Any]) -> dict[str, Any]: - provider_fields = source.get("provider_fields") - if isinstance(provider_fields, dict): - candidate = provider_fields.get("github_project_v2") - if isinstance(candidate, dict): - return dict(candidate) - fallback = source.get("github_project_v2") - if isinstance(fallback, dict): - return dict(fallback) - return {} - - def _extract_github_issue_types(source: dict[str, Any]) -> dict[str, Any]: - def _has_type_id_mapping(candidate: dict[str, Any]) -> bool: - raw_type_ids = candidate.get("type_ids") - if not isinstance(raw_type_ids, dict): - return False - return any(str(value).strip() for value in raw_type_ids.values()) - - provider_fields = source.get("provider_fields") - if isinstance(provider_fields, dict): - candidate = provider_fields.get("github_issue_types") - if isinstance(candidate, dict) and _has_type_id_mapping(candidate): - return dict(candidate) - fallback = source.get("github_issue_types") - if isinstance(fallback, dict) and _has_type_id_mapping(fallback): - return dict(fallback) - return {} - - spec_settings: dict[str, Any] = {} - backlog_cfg = load_backlog_config_from_backlog_file(repo_path / ".specfact" / "backlog-config.yaml") - spec_config = backlog_cfg or load_backlog_config_from_spec(repo_path / ".specfact" / "spec.yaml") - if spec_config is not None: - github_provider = spec_config.providers.get("github") - if github_provider is not None and isinstance(github_provider.settings, dict): - spec_settings = dict(github_provider.settings) - - template_cfg = _extract_github_project_v2(template_payload) - spec_cfg = _extract_github_project_v2(spec_settings) - custom_cfg = _extract_github_project_v2(custom_config) - - template_issue_types = _extract_github_issue_types(template_payload) - spec_issue_types = _extract_github_issue_types(spec_settings) - custom_issue_types = _extract_github_issue_types(custom_config) - - result: dict[str, Any] = {} - - if template_cfg or spec_cfg or custom_cfg: - template_option_ids = template_cfg.get("type_option_ids") - spec_option_ids = spec_cfg.get("type_option_ids") - custom_option_ids = custom_cfg.get("type_option_ids") - merged_option_ids: dict[str, Any] = {} - if isinstance(template_option_ids, dict): - merged_option_ids.update(template_option_ids) - if isinstance(spec_option_ids, dict): - merged_option_ids.update(spec_option_ids) - if isinstance(custom_option_ids, dict): - merged_option_ids.update(custom_option_ids) - - merged_cfg = {**template_cfg, **spec_cfg, **custom_cfg} - if merged_option_ids: - merged_cfg["type_option_ids"] = merged_option_ids - if merged_cfg: - result["github_project_v2"] = merged_cfg - - if template_issue_types or spec_issue_types or custom_issue_types: - template_type_ids = template_issue_types.get("type_ids") - spec_type_ids = spec_issue_types.get("type_ids") - custom_type_ids = custom_issue_types.get("type_ids") - merged_type_ids: dict[str, Any] = {} - if isinstance(template_type_ids, dict): - merged_type_ids.update(template_type_ids) - if isinstance(spec_type_ids, dict): - merged_type_ids.update(spec_type_ids) - if isinstance(custom_type_ids, dict): - merged_type_ids.update(custom_type_ids) - - issue_type_cfg = {**template_issue_types, **spec_issue_types, **custom_issue_types} - if merged_type_ids: - issue_type_cfg["type_ids"] = merged_type_ids - if issue_type_cfg: - result["github_issue_types"] = issue_type_cfg - - return result or None - - -@beartype -def _has_github_repo_issue_type_mapping(provider_fields: dict[str, Any] | None, issue_type: str) -> bool: - """Return True when repository GitHub issue-type mapping metadata is available.""" - if not isinstance(provider_fields, dict): - return False - issue_cfg = provider_fields.get("github_issue_types") - if not isinstance(issue_cfg, dict): - return False - type_ids = issue_cfg.get("type_ids") - if not isinstance(type_ids, dict): - return False - normalized = issue_type.strip().lower() - mapped = str(type_ids.get(issue_type) or type_ids.get(normalized) or "").strip() - if mapped: - return True - if normalized == "story": - fallback = str(type_ids.get("feature") or type_ids.get("Feature") or "").strip() - return bool(fallback) - return False - - -@beartype -def _resolve_parent_id(parent_ref: str, graph_items: dict[str, Any]) -> tuple[str | None, str | None]: - normalized_ref = parent_ref.strip().lower() - - for item_id, item in graph_items.items(): - key = str(getattr(item, "key", "") or "").lower() - title = str(getattr(item, "title", "") or "").lower() - if normalized_ref in {item_id.lower(), key, title}: - return item_id, _extract_item_type(item) - - return None, None - - -@beartype -def _validate_parent(child_type: str, parent_type: str, hierarchy: dict[str, list[str]]) -> bool: - allowed = hierarchy.get(child_type, []) - if not allowed: - return True - return parent_type in allowed - - -@beartype -def _choose_parent_interactively( - issue_type: str, - graph_items: dict[str, Any], - hierarchy: dict[str, list[str]], -) -> str | None: - """Interactively choose parent from existing hierarchy-compatible items.""" - add_parent_choice = _select_with_fallback("Add parent issue?", ["yes", "no"], default="yes") - if add_parent_choice.strip().lower() != "yes": - return None - - allowed = set(hierarchy.get(issue_type, [])) - all_candidates: list[tuple[str, str]] = [] - candidates: list[tuple[str, str]] = [] - for item_id, item in graph_items.items(): - parent_type = _extract_item_type(item) - key = str(getattr(item, "key", item_id) or item_id) - title = str(getattr(item, "title", "") or "") - label = f"{key} | {title} | type={parent_type}" if title else f"{key} | type={parent_type}" - all_candidates.append((label, item_id)) - if allowed and parent_type not in allowed: - continue - candidates.append((label, item_id)) - - if not candidates: - if all_candidates: - print_warning( - "No hierarchy-compatible parent candidates found from inferred types. " - "Showing all issues so you can choose a parent manually." - ) - candidates = all_candidates - else: - print_warning("No hierarchy-compatible parent candidates found. Continuing without parent.") - return None - - options = ["(no parent)"] + [label for label, _ in candidates] - default_option = options[1] if len(options) > 1 else options[0] - selected = _select_with_fallback("Select parent issue", options, default=default_option) - if selected == "(no parent)": - return None - - mapping = dict(candidates) - return mapping.get(selected) - - -@beartype -def _parse_story_points(raw_value: str | None) -> int | float | None: - if raw_value is None: - return None - stripped = raw_value.strip() - if not stripped: - return None - try: - if "." in stripped: - return float(stripped) - return int(stripped) - except ValueError: - print_warning(f"Invalid story points '{raw_value}', keeping as text") - return None - - -@beartype -def add( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - template: Annotated[str | None, typer.Option("--template", help="Template name for mapping")] = None, - issue_type: Annotated[str | None, typer.Option("--type", help="Issue type (story/task/feature/...)")] = None, - parent: Annotated[str | None, typer.Option("--parent", help="Parent issue id/key/title")] = None, - title: Annotated[str | None, typer.Option("--title", help="Issue title")] = None, - body: Annotated[str | None, typer.Option("--body", help="Issue body/description")] = None, - acceptance_criteria: Annotated[ - str | None, - typer.Option("--acceptance-criteria", help="Acceptance criteria text (recommended for story-like items)"), - ] = None, - priority: Annotated[str | None, typer.Option("--priority", help="Priority value (for example 1, high, P1)")] = None, - story_points: Annotated[ - str | None, typer.Option("--story-points", help="Story points value (integer/float)") - ] = None, - sprint: Annotated[str | None, typer.Option("--sprint", help="Sprint/iteration assignment")] = None, - body_end_marker: Annotated[ - str, - typer.Option("--body-end-marker", help="End marker for interactive multiline input"), - ] = "::END::", - description_format: Annotated[ - str, - typer.Option("--description-format", help="Description format: markdown or classic"), - ] = "markdown", - non_interactive: Annotated[bool, typer.Option("--non-interactive", help="Disable prompts")] = False, - check_dor: Annotated[ - bool, typer.Option("--check-dor", help="Validate Definition of Ready before creation") - ] = False, - repo_path: Annotated[Path, typer.Option("--repo-path", help="Repository path for DoR config")] = Path("."), - custom_config: Annotated[ - Path | None, typer.Option("--custom-config", help="Path to custom hierarchy/config YAML") - ] = None, - custom_field: Annotated[ - list[str] | None, - typer.Option( - "--custom-field", help="Custom field entry '='", metavar="KEY=VALUE" - ), - ] = None, -) -> None: - """Create a backlog item with optional parent hierarchy validation and DoR checks.""" - adapter_instance = AdapterRegistry.get_adapter(adapter) - interactive_mode = not non_interactive - - if non_interactive: - missing = [ - name for name, value in {"type": issue_type, "title": title}.items() if not (value and value.strip()) - ] - if missing: - print_error(f"{', '.join(missing)} required in --non-interactive mode") - raise typer.Exit(code=1) - else: - issue_type_choices = sorted(set(DEFAULT_CREATION_HIERARCHY.keys())) - if not issue_type: - issue_type = _select_with_fallback("Select issue type", issue_type_choices, default="story") - if not title: - title = prompt_text("Issue title") - if body is None: - body = _prompt_multiline_text("Issue body", body_end_marker) - if sprint is None: - sprint = _interactive_sprint_selection(adapter, adapter_instance, project_id) - description_format = _select_with_fallback( - "Select description format", - ["markdown", "classic"], - default=description_format or "markdown", - ).lower() - - normalized_issue_type = _normalize_type(issue_type or "") - if normalized_issue_type in STORY_LIKE_TYPES and acceptance_criteria is None: - capture_ac = _select_with_fallback("Add acceptance criteria?", ["yes", "no"], default="yes") - if capture_ac.strip().lower() == "yes": - acceptance_criteria = _prompt_multiline_text("Acceptance criteria", body_end_marker) - - if priority is None: - priority_raw = prompt_text("Priority (optional)", default="", required=False).strip() - priority = priority_raw or None - - if story_points is None and normalized_issue_type in STORY_LIKE_TYPES: - story_points = prompt_text("Story points (optional)", default="", required=False).strip() or None - - assert issue_type is not None - assert title is not None - issue_type = _normalize_type(issue_type) - title = title.strip() - body = (body or "").strip() - acceptance_criteria = (acceptance_criteria or "").strip() or None - priority = (priority or "").strip() or None - - description_format = (description_format or "markdown").strip().lower() - if description_format not in {"markdown", "classic"}: - print_error("description-format must be one of: markdown, classic") - raise typer.Exit(code=1) - - parsed_story_points = _parse_story_points(story_points) - - graph_adapter = require_backlog_graph_protocol(adapter_instance) - - template = _resolve_default_template(adapter, template) - print_info("Input captured. Preparing backlog context and validations before create...") - - resolved_custom_config = _resolve_custom_config_path(adapter, custom_config) - custom = _load_custom_config(resolved_custom_config) - template_payload = _load_template_config(template) - - fetch_filters = dict(custom.get("filters") or {}) - if adapter.strip().lower() == "ado": - fetch_filters.setdefault("use_current_iteration_default", False) - items = graph_adapter.fetch_all_issues(project_id, filters=fetch_filters) - relationships = graph_adapter.fetch_relationships(project_id) - - graph = ( - BacklogGraphBuilder( - provider=adapter, - template_name=template, - custom_config={**custom, "project_key": project_id}, - ) - .add_items(items) - .add_dependencies(relationships) - .build() - ) - - hierarchy = _derive_creation_hierarchy(template_payload, custom) - - parent_id: str | None = None - if parent: - parent_id, parent_type = _resolve_parent_id(parent, graph.items) - if not parent_id or not parent_type: - print_error(f"Parent '{parent}' not found") - raise typer.Exit(code=1) - if not _validate_parent(issue_type, parent_type, hierarchy): - allowed = hierarchy.get(issue_type, []) - print_error( - f"Type '{issue_type}' is not allowed under parent type '{parent_type}'. " - f"Allowed parent types: {', '.join(allowed) if allowed else '(any)'}" - ) - raise typer.Exit(code=1) - elif interactive_mode: - parent_id = _choose_parent_interactively(issue_type, graph.items, hierarchy) - - payload: dict[str, Any] = { - "type": issue_type, - "title": title, - "description": body, - "description_format": description_format, - } - if acceptance_criteria: - payload["acceptance_criteria"] = acceptance_criteria - if priority: - payload["priority"] = priority - if parsed_story_points is not None: - payload["story_points"] = parsed_story_points - if parent_id: - payload["parent_id"] = parent_id - if sprint: - payload["sprint"] = sprint - - provider_fields = _resolve_provider_fields_for_create(adapter, template_payload, custom, repo_path) - if adapter.strip().lower() == "ado": - try: - ado_provider_fields = _resolve_ado_custom_fields(repo_path, issue_type, custom_field) - except ValueError as error: - print_error(str(error)) - raise typer.Exit(code=1) from error - if ado_provider_fields: - provider_fields = dict(provider_fields or {}) - provider_fields.update(ado_provider_fields) - if provider_fields: - payload["provider_fields"] = provider_fields - - if adapter.strip().lower() == "github" and not _has_github_repo_issue_type_mapping(provider_fields, issue_type): - print_warning( - "GitHub repository issue-type mapping is not configured for this issue type; " - "issue type may fall back to labels/body only. Configure " - "backlog_config.providers.github.settings.github_issue_types.type_ids " - "(ProjectV2 mapping is optional) to enable automatic issue Type updates." - ) - - if check_dor: - dor_config = DefinitionOfReady.load_from_repo(repo_path) - if dor_config: - draft = { - "id": "DRAFT", - "title": title, - "body_markdown": body, - "description": body, - "type": issue_type, - "provider_fields": { - "acceptance_criteria": acceptance_criteria, - "priority": priority, - "story_points": parsed_story_points, - }, - } - dor_errors = dor_config.validate_item(draft) - if dor_errors: - print_warning("Definition of Ready (DoR) issues detected:") - for err in dor_errors: - print_warning(err) - raise typer.Exit(code=1) - print_info("Definition of Ready (DoR) satisfied") - - create_context = f"adapter={adapter}, format={description_format}" - if sprint: - create_context += f", sprint={sprint}" - if parent_id: - create_context += ", parent=selected" - print_info(f"Creating backlog item now ({create_context})...") - - try: - created = graph_adapter.create_issue(project_id, payload) - except (requests.Timeout, requests.ConnectionError) as error: - print_warning("Create request failed after send; item may already exist remotely.") - print_warning("Verify backlog for the title/key before retrying to avoid duplicates.") - raise typer.Exit(code=1) from error - print_success("Issue created successfully") - print_info(f"id: {created.get('id', '')}") - print_info(f"key: {created.get('key', '')}") - print_info(f"url: {created.get('url', '')}") diff --git a/modules/backlog-core/src/backlog_core/commands/analyze_deps.py b/modules/backlog-core/src/backlog_core/commands/analyze_deps.py deleted file mode 100644 index 94c501e5..00000000 --- a/modules/backlog-core/src/backlog_core/commands/analyze_deps.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Backlog dependency analysis commands.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated, Any - -import typer -import yaml -from beartype import beartype -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -from backlog_core.adapters.backlog_protocol import require_backlog_graph_protocol -from backlog_core.analyzers.dependency import DependencyAnalyzer -from backlog_core.graph.builder import BacklogGraphBuilder -from backlog_core.graph.models import BacklogGraph -from specfact_cli.adapters.registry import AdapterRegistry -from specfact_cli.utils.prompts import print_info, print_success, print_warning - - -console = Console() - - -@beartype -def _load_custom_config(custom_config: Path | None) -> dict[str, Any]: - if custom_config is None: - return {} - if not custom_config.exists(): - raise ValueError(f"Custom config file not found: {custom_config}") - loaded = yaml.safe_load(custom_config.read_text(encoding="utf-8")) - return loaded if isinstance(loaded, dict) else {} - - -@beartype -def _compute_graph( - project_id: str, - adapter: str, - template: str, - custom_config: Path | None, -) -> tuple[BacklogGraph, DependencyAnalyzer]: - adapter_instance = AdapterRegistry.get_adapter(adapter) - graph_adapter = require_backlog_graph_protocol(adapter_instance) - - custom = _load_custom_config(custom_config) - items = graph_adapter.fetch_all_issues(project_id, filters=custom.get("filters")) - relationships = graph_adapter.fetch_relationships(project_id) - - builder = BacklogGraphBuilder( - provider=adapter, - template_name=template, - custom_config={**custom, "project_key": project_id}, - ) - graph = builder.add_items(items).add_dependencies(relationships).build() - analyzer = DependencyAnalyzer(graph) - return graph, analyzer - - -@beartype -def export_graph_json(graph: BacklogGraph, export_path: Path) -> None: - """Export graph as JSON.""" - export_path.parent.mkdir(parents=True, exist_ok=True) - export_path.write_text(graph.to_json(), encoding="utf-8") - - -@beartype -def generate_dependency_report(graph: BacklogGraph, analyzer: DependencyAnalyzer) -> str: - """Render dependency analysis summary in rich tables and return markdown summary.""" - cycles = analyzer.detect_cycles() - critical_path = analyzer.critical_path() - coverage = analyzer.coverage_analysis() - - summary = Table(title="Dependency Analysis Summary") - summary.add_column("Metric", style="cyan") - summary.add_column("Value", style="green") - summary.add_row("Provider", graph.provider) - summary.add_row("Project", graph.project_key) - summary.add_row("Items", str(len(graph.items))) - summary.add_row("Dependencies", str(len(graph.dependencies))) - summary.add_row("Cycles", str(len(cycles))) - summary.add_row("Orphans", str(len(graph.orphans))) - summary.add_row("Critical Path Length", str(len(critical_path))) - summary.add_row("Typed Coverage", f"{coverage['properly_typed_pct']}%") - - console.print(Panel("Dependency Graph Analysis", border_style="blue")) - console.print(summary) - - markdown_lines = [ - "# Dependency Analysis", - "", - f"- Provider: {graph.provider}", - f"- Project: {graph.project_key}", - f"- Items: {len(graph.items)}", - f"- Dependencies: {len(graph.dependencies)}", - f"- Cycles: {len(cycles)}", - f"- Orphans: {len(graph.orphans)}", - f"- Critical path: {' -> '.join(critical_path) if critical_path else '(none)'}", - f"- Typed coverage: {coverage['properly_typed_pct']}%", - ] - return "\n".join(markdown_lines) + "\n" - - -@beartype -def analyze_deps( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", - custom_config: Annotated[Path | None, typer.Option("--custom-config", help="Path to custom mapping YAML")] = None, - output: Annotated[Path | None, typer.Option("--output", help="Optional markdown report output path")] = None, - json_export: Annotated[Path | None, typer.Option("--json-export", help="Optional graph JSON export path")] = None, -) -> None: - """Analyze backlog dependencies for a project.""" - graph, analyzer = _compute_graph(project_id, adapter, template, custom_config) - - report_markdown = generate_dependency_report(graph, analyzer) - if output is not None: - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(report_markdown, encoding="utf-8") - print_success(f"Dependency report written to {output}") - - if json_export is not None: - export_graph_json(graph, json_export) - print_success(f"Dependency graph JSON exported to {json_export}") - - -@beartype -def trace_impact( - item_id: Annotated[str, typer.Argument(help="Item ID to analyze")], - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", - custom_config: Annotated[Path | None, typer.Option("--custom-config", help="Path to custom mapping YAML")] = None, -) -> None: - """Trace downstream impact for a backlog item.""" - graph, analyzer = _compute_graph(project_id, adapter, template, custom_config) - - if item_id not in graph.items: - print_warning(f"Item '{item_id}' not found in graph") - raise typer.Exit(code=1) - - impact = analyzer.impact_analysis(item_id) - print_info(f"Direct dependents: {', '.join(impact['direct_dependents']) or '(none)'}") - print_info(f"Transitive dependents: {', '.join(impact['transitive_dependents']) or '(none)'}") - print_info(f"Blockers: {', '.join(impact['blockers']) or '(none)'}") - print_success(f"Estimated impact count: {impact['estimated_impact_count']}") diff --git a/modules/backlog-core/src/backlog_core/commands/delta.py b/modules/backlog-core/src/backlog_core/commands/delta.py deleted file mode 100644 index 574f593a..00000000 --- a/modules/backlog-core/src/backlog_core/commands/delta.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Delta analysis subcommands for backlog-core.""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -from typing import Annotated, Any - -import typer -from beartype import beartype -from rich.console import Console -from rich.table import Table - -from backlog_core.adapters.backlog_protocol import require_backlog_graph_protocol -from backlog_core.analyzers.dependency import DependencyAnalyzer -from backlog_core.commands.sync import compute_delta -from backlog_core.graph.builder import BacklogGraphBuilder -from backlog_core.graph.models import BacklogGraph -from specfact_cli.adapters.registry import AdapterRegistry -from specfact_cli.utils.prompts import print_info, print_success, print_warning - - -console = Console() -delta_app = typer.Typer(name="delta", help="Backlog delta analysis and impact tracking") - - -@beartype -def _load_baseline_graph(baseline_file: Path) -> BacklogGraph: - if not baseline_file.exists(): - return BacklogGraph(provider="unknown", project_key="unknown") - return BacklogGraph.from_json(baseline_file.read_text(encoding="utf-8")) - - -@beartype -def _fetch_current_graph(project_id: str, adapter: str, template: str) -> BacklogGraph: - adapter_instance = AdapterRegistry.get_adapter(adapter) - graph_adapter = require_backlog_graph_protocol(adapter_instance) - items = graph_adapter.fetch_all_issues(project_id) - relationships = graph_adapter.fetch_relationships(project_id) - return ( - BacklogGraphBuilder(provider=adapter, template_name=template, custom_config={"project_key": project_id}) - .add_items(items) - .add_dependencies(relationships) - .build() - ) - - -@beartype -def _empty_delta() -> dict[str, Any]: - return { - "added_items": [], - "removed_items": [], - "updated_items": [], - "status_transitions": [], - "new_dependencies": [], - "removed_dependencies": [], - } - - -@beartype -def _render_delta_table(delta: dict[str, Any], title: str = "Delta Status") -> None: - table = Table(title=title) - table.add_column("Metric", style="cyan") - table.add_column("Count", style="green") - table.add_row("Added items", str(len(delta["added_items"]))) - table.add_row("Updated items", str(len(delta["updated_items"]))) - table.add_row("Removed items", str(len(delta["removed_items"]))) - table.add_row("Status transitions", str(len(delta["status_transitions"]))) - table.add_row("New dependencies", str(len(delta["new_dependencies"]))) - table.add_row("Removed dependencies", str(len(delta["removed_dependencies"]))) - console.print(table) - - -@beartype -def status( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - since: Annotated[str | None, typer.Option("--since", help="ISO timestamp filter")] = None, - baseline_file: Annotated[Path, typer.Option("--baseline-file", help="Path to baseline graph JSON")] = Path( - ".specfact/backlog-baseline.json" - ), - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Show backlog delta status compared to baseline.""" - baseline_graph = _load_baseline_graph(baseline_file) - current_graph = _fetch_current_graph(project_id, adapter, template) - delta = compute_delta(baseline_graph, current_graph) - - if since is not None: - try: - since_dt = datetime.fromisoformat(since) - except ValueError as exc: - raise typer.BadParameter(f"Invalid --since timestamp: {since}") from exc - if current_graph.fetched_at.replace(tzinfo=None) <= since_dt.replace(tzinfo=None): - print_warning("No changes after --since timestamp.") - delta = _empty_delta() - - _render_delta_table(delta, title="Backlog Delta Status") - - -@beartype -def impact( - item_id: Annotated[str, typer.Argument(help="Item id to inspect")], - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Show downstream dependency impact for an item.""" - graph = _fetch_current_graph(project_id, adapter, template) - analyzer = DependencyAnalyzer(graph) - if item_id not in graph.items: - print_warning(f"Item '{item_id}' not found in graph.") - raise typer.Exit(code=1) - result = analyzer.impact_analysis(item_id) - print_info(f"Direct dependents: {', '.join(result['direct_dependents']) or '(none)'}") - print_info(f"Transitive dependents: {', '.join(result['transitive_dependents']) or '(none)'}") - print_success(f"Estimated impact count: {result['estimated_impact_count']}") - - -@beartype -def cost_estimate( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - baseline_file: Annotated[Path, typer.Option("--baseline-file", help="Path to baseline graph JSON")] = Path( - ".specfact/backlog-baseline.json" - ), - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Estimate effort based on detected delta volume.""" - baseline_graph = _load_baseline_graph(baseline_file) - current_graph = _fetch_current_graph(project_id, adapter, template) - delta = compute_delta(baseline_graph, current_graph) - - points = ( - len(delta["added_items"]) * 3 - + len(delta["updated_items"]) * 2 - + len(delta["removed_items"]) - + len(delta["new_dependencies"]) - ) - print_success(f"Estimated delta effort points: {points}") - - -@beartype -def rollback_analysis( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - baseline_file: Annotated[Path, typer.Option("--baseline-file", help="Path to baseline graph JSON")] = Path( - ".specfact/backlog-baseline.json" - ), - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Analyze rollback exposure from current delta.""" - baseline_graph = _load_baseline_graph(baseline_file) - current_graph = _fetch_current_graph(project_id, adapter, template) - delta = compute_delta(baseline_graph, current_graph) - - high_risk = len(delta["removed_items"]) > 0 or len(delta["removed_dependencies"]) > 0 - risk = "HIGH" if high_risk else "LOW" - print_info(f"Rollback risk: {risk}") - _render_delta_table(delta, title="Rollback Impact") - - -delta_app.command("status")(status) -delta_app.command("impact")(impact) -delta_app.command("cost-estimate")(cost_estimate) -delta_app.command("rollback-analysis")(rollback_analysis) diff --git a/modules/backlog-core/src/backlog_core/commands/diff.py b/modules/backlog-core/src/backlog_core/commands/diff.py deleted file mode 100644 index f95dc2ab..00000000 --- a/modules/backlog-core/src/backlog_core/commands/diff.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Backlog diff command.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Annotated - -import typer -from beartype import beartype -from rich.console import Console -from rich.table import Table - -from backlog_core.commands.sync import compute_delta -from backlog_core.graph.models import BacklogGraph -from specfact_cli.utils.prompts import print_warning - -from .shared import fetch_current_graph - - -console = Console() - - -@beartype -def _load_baseline_graph(baseline_file: Path) -> BacklogGraph: - if not baseline_file.exists(): - return BacklogGraph(provider="unknown", project_key="unknown") - return BacklogGraph.from_json(baseline_file.read_text(encoding="utf-8")) - - -@beartype -def _render_diff(delta: dict[str, object]) -> None: - table = Table(title="Backlog Diff") - table.add_column("Metric", style="cyan") - table.add_column("Count", style="green") - table.add_row("Added items", str(len(delta["added_items"]))) - table.add_row("Updated items", str(len(delta["updated_items"]))) - table.add_row("Removed items", str(len(delta["removed_items"]))) - table.add_row("Status transitions", str(len(delta["status_transitions"]))) - table.add_row("New dependencies", str(len(delta["new_dependencies"]))) - table.add_row("Removed dependencies", str(len(delta["removed_dependencies"]))) - console.print(table) - - -@beartype -def diff( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - baseline_file: Annotated[Path, typer.Option("--baseline-file", help="Path to baseline graph JSON")] = Path( - ".specfact/backlog-baseline.json" - ), - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Show changes since baseline sync.""" - if not baseline_file.exists(): - print_warning(f"Baseline file not found: {baseline_file}. Comparing against empty baseline.") - baseline_graph = _load_baseline_graph(baseline_file) - current_graph = fetch_current_graph(project_id, adapter, template) - delta = compute_delta(baseline_graph, current_graph) - _render_diff(delta) diff --git a/modules/backlog-core/src/backlog_core/commands/promote.py b/modules/backlog-core/src/backlog_core/commands/promote.py deleted file mode 100644 index 9ebde9dc..00000000 --- a/modules/backlog-core/src/backlog_core/commands/promote.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Backlog promote command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer -from beartype import beartype - -from backlog_core.analyzers.dependency import DependencyAnalyzer -from specfact_cli.utils.prompts import print_info, print_success, print_warning - -from .shared import fetch_current_graph - - -@beartype -def promote( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - item_id: Annotated[str, typer.Option("--item-id", help="Item id to promote")], - to_status: Annotated[str, typer.Option("--to-status", help="Target workflow status")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Validate promotion impact for an item and print promotion intent.""" - graph = fetch_current_graph(project_id, adapter, template) - item = graph.items.get(item_id) - if item is None: - print_warning(f"Item '{item_id}' not found in graph.") - raise typer.Exit(code=1) - - analyzer = DependencyAnalyzer(graph) - impact = analyzer.impact_analysis(item_id) - blockers = impact["blockers"] - if blockers: - print_warning(f"Item '{item_id}' has blockers: {', '.join(blockers)}") - - print_info(f"Promote item '{item_id}' from '{item.status}' to '{to_status}'") - print_info(f"Downstream impact count: {impact['estimated_impact_count']}") - if blockers: - raise typer.Exit(code=1) - print_success("Promotion validation passed.") diff --git a/modules/backlog-core/src/backlog_core/commands/release_notes.py b/modules/backlog-core/src/backlog_core/commands/release_notes.py deleted file mode 100644 index f9a5f70d..00000000 --- a/modules/backlog-core/src/backlog_core/commands/release_notes.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Backlog release notes generation command.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from pathlib import Path -from typing import Annotated - -import typer -from beartype import beartype - -from specfact_cli.utils.prompts import print_success - -from .shared import fetch_current_graph - - -DONE_STATES = {"done", "completed", "closed", "resolved"} - - -@beartype -def _default_output_path() -> Path: - timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") - return Path(".specfact/release-notes") / f"release-notes-{timestamp}.md" - - -@beartype -def _render_release_notes(project_id: str, adapter: str, done_items: list[tuple[str, str, str]]) -> str: - lines = [ - "# Release Notes", - "", - f"- Project: {project_id}", - f"- Adapter: {adapter}", - f"- Generated: {datetime.now(UTC).isoformat()}", - "", - "## Completed Items", - "", - ] - if not done_items: - lines.append("- No completed items found.") - else: - for item_id, key, title in done_items: - lines.append(f"- {key or item_id}: {title}") - lines.append("") - return "\n".join(lines) - - -@beartype -def generate_release_notes( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - output: Annotated[Path | None, typer.Option("--output", help="Release notes markdown output path")] = None, - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Generate release notes from completed backlog items.""" - graph = fetch_current_graph(project_id, adapter, template) - done_items = [ - (item.id, item.key, item.title) for item in graph.items.values() if item.status.lower().strip() in DONE_STATES - ] - done_items.sort(key=lambda row: row[1] or row[0]) - - output_path = output or _default_output_path() - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(_render_release_notes(project_id, adapter, done_items), encoding="utf-8") - print_success(f"Release notes written: {output_path}") diff --git a/modules/backlog-core/src/backlog_core/commands/shared.py b/modules/backlog-core/src/backlog_core/commands/shared.py deleted file mode 100644 index 042a2b16..00000000 --- a/modules/backlog-core/src/backlog_core/commands/shared.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Shared command helpers for backlog-core command modules.""" - -from __future__ import annotations - -from beartype import beartype - -from backlog_core.adapters.backlog_protocol import require_backlog_graph_protocol -from backlog_core.graph.builder import BacklogGraphBuilder -from backlog_core.graph.models import BacklogGraph -from specfact_cli.adapters.registry import AdapterRegistry - - -@beartype -def fetch_current_graph(project_id: str, adapter: str, template: str) -> BacklogGraph: - """Fetch and build the current backlog dependency graph.""" - adapter_instance = AdapterRegistry.get_adapter(adapter) - graph_adapter = require_backlog_graph_protocol(adapter_instance) - items = graph_adapter.fetch_all_issues(project_id) - relationships = graph_adapter.fetch_relationships(project_id) - return ( - BacklogGraphBuilder(provider=adapter, template_name=template, custom_config={"project_key": project_id}) - .add_items(items) - .add_dependencies(relationships) - .build() - ) diff --git a/modules/backlog-core/src/backlog_core/commands/sync.py b/modules/backlog-core/src/backlog_core/commands/sync.py deleted file mode 100644 index 09f44a53..00000000 --- a/modules/backlog-core/src/backlog_core/commands/sync.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Backlog sync command and delta utilities.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from pathlib import Path -from typing import Annotated, Any - -import typer -import yaml -from beartype import beartype -from rich.console import Console -from rich.table import Table - -from backlog_core.adapters.backlog_protocol import require_backlog_graph_protocol -from backlog_core.graph.builder import BacklogGraphBuilder -from backlog_core.graph.models import BacklogGraph -from specfact_cli.adapters.registry import AdapterRegistry -from specfact_cli.utils.prompts import print_info, print_success, print_warning - - -console = Console() - - -@beartype -def _load_baseline_graph(baseline_file: Path) -> BacklogGraph: - if not baseline_file.exists(): - return BacklogGraph(provider="unknown", project_key="unknown") - return BacklogGraph.from_json(baseline_file.read_text(encoding="utf-8")) - - -@beartype -def _fetch_current_graph( - project_id: str, - adapter: str, - template: str, -) -> BacklogGraph: - adapter_instance = AdapterRegistry.get_adapter(adapter) - graph_adapter = require_backlog_graph_protocol(adapter_instance) - items = graph_adapter.fetch_all_issues(project_id) - relationships = graph_adapter.fetch_relationships(project_id) - - return ( - BacklogGraphBuilder(provider=adapter, template_name=template, custom_config={"project_key": project_id}) - .add_items(items) - .add_dependencies(relationships) - .build() - ) - - -@beartype -def compute_delta(baseline_graph: BacklogGraph, current_graph: BacklogGraph) -> dict[str, Any]: - baseline_ids = set(baseline_graph.items.keys()) - current_ids = set(current_graph.items.keys()) - - added_items = sorted(current_ids - baseline_ids) - removed_items = sorted(baseline_ids - current_ids) - common_ids = baseline_ids & current_ids - - updated_items: list[str] = [] - status_transitions: list[dict[str, str]] = [] - for item_id in sorted(common_ids): - old_item = baseline_graph.items[item_id] - new_item = current_graph.items[item_id] - if old_item.model_dump(exclude={"raw_data"}) != new_item.model_dump(exclude={"raw_data"}): - updated_items.append(item_id) - if old_item.status != new_item.status: - status_transitions.append({"id": item_id, "from": old_item.status, "to": new_item.status}) - - baseline_edges = {(dep.source_id, dep.target_id, dep.type.value) for dep in baseline_graph.dependencies} - current_edges = {(dep.source_id, dep.target_id, dep.type.value) for dep in current_graph.dependencies} - - return { - "added_items": added_items, - "removed_items": removed_items, - "updated_items": updated_items, - "status_transitions": status_transitions, - "new_dependencies": sorted(current_edges - baseline_edges), - "removed_dependencies": sorted(baseline_edges - current_edges), - } - - -@beartype -class BacklogGraphToPlanBundle: - """Convert backlog sync state into a plan-bundle-shaped payload.""" - - @beartype - def convert( - self, - graph: BacklogGraph, - delta: dict[str, Any], - project_id: str, - adapter: str, - ) -> dict[str, Any]: - return { - "bundle_name": f"backlog-sync-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}", - "metadata": { - "generated_at": datetime.now(UTC).isoformat(), - "source": {"project_id": project_id, "adapter": adapter}, - "delta_summary": { - "added": len(delta["added_items"]), - "updated": len(delta["updated_items"]), - "removed": len(delta["removed_items"]), - "new_dependencies": len(delta["new_dependencies"]), - }, - }, - "backlog_graph": graph.model_dump(mode="json"), - } - - -@beartype -def _render_delta_summary(delta: dict[str, Any]) -> None: - table = Table(title="Backlog Sync Delta") - table.add_column("Metric", style="cyan") - table.add_column("Count", style="green") - table.add_row("Added items", str(len(delta["added_items"]))) - table.add_row("Updated items", str(len(delta["updated_items"]))) - table.add_row("Removed items", str(len(delta["removed_items"]))) - table.add_row("Status transitions", str(len(delta["status_transitions"]))) - table.add_row("New dependencies", str(len(delta["new_dependencies"]))) - table.add_row("Removed dependencies", str(len(delta["removed_dependencies"]))) - console.print(table) - - -@beartype -def sync( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - baseline_file: Annotated[Path, typer.Option("--baseline-file", help="Path to baseline graph JSON")] = Path( - ".specfact/backlog-baseline.json" - ), - output_format: Annotated[str, typer.Option("--output-format", help="Output format: plan|json")] = "plan", - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Sync current backlog graph with stored baseline and export delta outputs.""" - if output_format not in {"plan", "json"}: - print_warning(f"Unsupported output format '{output_format}'. Use 'plan' or 'json'.") - raise typer.Exit(code=1) - - baseline_graph = _load_baseline_graph(baseline_file) - current_graph = _fetch_current_graph(project_id, adapter, template) - delta = compute_delta(baseline_graph, current_graph) - - _render_delta_summary(delta) - - baseline_file.parent.mkdir(parents=True, exist_ok=True) - baseline_file.write_text(current_graph.to_json(), encoding="utf-8") - print_success(f"Updated baseline graph: {baseline_file}") - - if output_format == "plan": - converter = BacklogGraphToPlanBundle() - bundle_payload = converter.convert(current_graph, delta, project_id, adapter) - plans_dir = Path(".specfact/plans") - plans_dir.mkdir(parents=True, exist_ok=True) - output_path = plans_dir / f"backlog-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}.yaml" - output_path.write_text(yaml.safe_dump(bundle_payload, sort_keys=False), encoding="utf-8") - print_success(f"Plan bundle written: {output_path}") - else: - print_info("JSON output selected: baseline file contains current graph snapshot.") diff --git a/modules/backlog-core/src/backlog_core/commands/verify.py b/modules/backlog-core/src/backlog_core/commands/verify.py deleted file mode 100644 index d1c56801..00000000 --- a/modules/backlog-core/src/backlog_core/commands/verify.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Release readiness verification command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer -from beartype import beartype -from rich.console import Console -from rich.panel import Panel - -from backlog_core.adapters.backlog_protocol import require_backlog_graph_protocol -from backlog_core.analyzers.dependency import DependencyAnalyzer -from backlog_core.graph.builder import BacklogGraphBuilder -from backlog_core.graph.models import DependencyType -from specfact_cli.adapters.registry import AdapterRegistry -from specfact_cli.utils.prompts import print_warning - - -console = Console() -DONE_STATES = {"done", "completed", "closed", "resolved"} -KNOWN_STATES = {"todo", "new", "planned", "in_progress", "active", "blocked", "open", *DONE_STATES} - - -@beartype -def _fetch_graph(project_id: str, adapter: str, template: str): - adapter_instance = AdapterRegistry.get_adapter(adapter) - graph_adapter = require_backlog_graph_protocol(adapter_instance) - items = graph_adapter.fetch_all_issues(project_id) - relationships = graph_adapter.fetch_relationships(project_id) - return ( - BacklogGraphBuilder(provider=adapter, template_name=template, custom_config={"project_key": project_id}) - .add_items(items) - .add_dependencies(relationships) - .build() - ) - - -@beartype -def verify_readiness( - project_id: Annotated[str, typer.Option("--project-id", help="Backlog project identifier")], - adapter: Annotated[str, typer.Option("--adapter", help="Adapter to use")] = "github", - target_items: Annotated[str, typer.Option("--target-items", help="Comma-separated item ids to verify")] = "", - template: Annotated[str, typer.Option("--template", help="Template name for mapping")] = "github_projects", -) -> None: - """Verify release readiness for selected backlog items.""" - graph = _fetch_graph(project_id, adapter, template) - analyzer = DependencyAnalyzer(graph) - target_ids = [x.strip() for x in target_items.split(",") if x.strip()] - if not target_ids: - target_ids = sorted(graph.items.keys()) - - findings: list[str] = [] - - cycles = analyzer.detect_cycles() - if cycles: - findings.append(f"Circular dependencies detected ({len(cycles)} cycles).") - - for item_id in target_ids: - if item_id not in graph.items: - findings.append(f"Target item '{item_id}' not found.") - continue - - impact = analyzer.impact_analysis(item_id) - if impact["blockers"]: - findings.append(f"Item '{item_id}' has blockers: {', '.join(impact['blockers'])}.") - - # Status transition/readiness sanity: unknown states are treated as not release-ready. - current_status = graph.items[item_id].status.lower().strip() - if current_status not in KNOWN_STATES: - findings.append(f"Item '{item_id}' has unrecognized status '{graph.items[item_id].status}'.") - - parent_to_children: dict[str, list[str]] = {} - for dep in graph.dependencies: - if dep.type != DependencyType.PARENT_CHILD: - continue - parent_to_children.setdefault(dep.source_id, []).append(dep.target_id) - - for parent_id, children in parent_to_children.items(): - parent = graph.items.get(parent_id) - if parent is None: - continue - if parent.status.lower() not in DONE_STATES: - continue - not_done_children = [ - child - for child in children - if graph.items.get(child) and graph.items[child].status.lower() not in DONE_STATES - ] - if not_done_children: - findings.append( - f"Parent '{parent_id}' is in completed state but children not completed: {', '.join(sorted(not_done_children))}." - ) - - ready = len(findings) == 0 - if ready: - console.print(Panel("Release readiness: READY", style="green")) - raise typer.Exit(code=0) - - for finding in findings: - print_warning(finding) - console.print(Panel("Release readiness: BLOCKED", style="red")) - raise typer.Exit(code=1) diff --git a/modules/backlog-core/src/backlog_core/graph/__init__.py b/modules/backlog-core/src/backlog_core/graph/__init__.py deleted file mode 100644 index 57775f34..00000000 --- a/modules/backlog-core/src/backlog_core/graph/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Backlog graph models and builders.""" - -from .builder import BacklogGraphBuilder -from .config_schema import BacklogConfigSchema, DependencyConfig, ProviderConfig -from .models import BacklogGraph, BacklogItem, Dependency, DependencyType, ItemType - - -__all__ = [ - "BacklogConfigSchema", - "BacklogGraph", - "BacklogGraphBuilder", - "BacklogItem", - "Dependency", - "DependencyConfig", - "DependencyType", - "ItemType", - "ProviderConfig", -] diff --git a/modules/backlog-core/src/backlog_core/graph/builder.py b/modules/backlog-core/src/backlog_core/graph/builder.py deleted file mode 100644 index 92737c40..00000000 --- a/modules/backlog-core/src/backlog_core/graph/builder.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Template-driven provider-to-graph mapping utilities.""" - -from __future__ import annotations - -from collections import defaultdict -from pathlib import Path -from typing import Any - -import yaml -from beartype import beartype -from icontract import ensure, require -from pydantic import BaseModel, Field - -from backlog_core.graph.config_schema import BacklogConfigSchema, load_backlog_config_from_spec -from backlog_core.graph.models import BacklogGraph, BacklogItem, Dependency, DependencyType, ItemType - - -class BacklogConfigModel(BaseModel): - """Optional configuration overrides for backlog graph mapping.""" - - template: str | None = Field(default=None, description="Preferred template override") - type_mapping: dict[str, str] = Field(default_factory=dict, description="Raw type -> normalized type mapping") - dependency_rules: dict[str, str] = Field( - default_factory=dict, - description="Raw relationship type -> normalized dependency mapping", - ) - status_mapping: dict[str, str] = Field(default_factory=dict, description="Raw status -> normalized status mapping") - creation_hierarchy: dict[str, list[str]] = Field( - default_factory=dict, - description="Allowed parent types per child type", - ) - - -@beartype -class BacklogGraphBuilder: - """Build provider-agnostic backlog graphs from provider payloads.""" - - @beartype - @require(lambda provider: provider.strip() != "", "Provider must be non-empty") - def __init__( - self, provider: str, template_name: str | None = None, custom_config: dict[str, Any] | None = None - ) -> None: - self.provider = provider - self.template_name = template_name or provider - self._template = self._load_template(self.template_name) - self._custom_config = self._load_custom_config(custom_config) - self._items: dict[str, BacklogItem] = {} - self._dependencies: list[Dependency] = [] - - @beartype - @require(lambda template_name: template_name.strip() != "", "Template name must be non-empty") - @ensure(lambda result: isinstance(result, dict), "Template loader must return dict") - def _load_template(self, template_name: str) -> dict[str, Any]: - module_root = Path(__file__).resolve().parents[1] - template_file = module_root / "resources" / "backlog-templates" / f"{template_name}.yaml" - shared_template_file = ( - Path(__file__).resolve().parents[5] - / "src" - / "specfact_cli" - / "resources" - / "backlog-templates" - / f"{template_name}.yaml" - ) - - for candidate in (template_file, shared_template_file): - if candidate.exists(): - data = yaml.safe_load(candidate.read_text(encoding="utf-8")) - if isinstance(data, dict): - return data - return {"type_mapping": {}, "dependency_rules": {}, "status_mapping": {}} - - @beartype - @ensure(lambda result: isinstance(result, dict), "Custom config must normalize to dict") - def _load_custom_config(self, custom_config: dict[str, Any] | None) -> dict[str, Any]: - merged = BacklogConfigModel().model_dump() - - spec_config = load_backlog_config_from_spec(Path(".specfact/spec.yaml")) - if spec_config is not None: - merged = self._merge_config(merged, self._flatten_config_payload(spec_config.model_dump())) - - if custom_config: - project_bundle_metadata = custom_config.get("project_bundle_metadata") - if isinstance(project_bundle_metadata, dict): - metadata_backlog_config = project_bundle_metadata.get("backlog_config") - if not isinstance(metadata_backlog_config, dict): - backlog_core = project_bundle_metadata.get("backlog_core") - if isinstance(backlog_core, dict): - metadata_backlog_config = backlog_core.get("backlog_config") - if not isinstance(metadata_backlog_config, dict): - extensions = project_bundle_metadata.get("extensions") - if isinstance(extensions, dict): - backlog_core_extension = extensions.get("backlog_core") - if isinstance(backlog_core_extension, dict): - metadata_backlog_config = backlog_core_extension.get("backlog_config") - if isinstance(metadata_backlog_config, dict): - merged = self._merge_config(merged, self._flatten_config_payload(metadata_backlog_config)) - merged = self._merge_config(merged, self._flatten_config_payload(custom_config)) - - return merged - - @beartype - @ensure(lambda result: isinstance(result, dict), "Flattened config must be dict") - def _flatten_config_payload(self, config_payload: dict[str, Any]) -> dict[str, Any]: - if "dependencies" in config_payload or "providers" in config_payload: - schema = BacklogConfigSchema.model_validate(config_payload) - dependency_data = schema.dependencies.model_dump() - return { - "template": dependency_data.get("template"), - "type_mapping": dependency_data.get("type_mapping", {}), - "dependency_rules": dependency_data.get("dependency_rules", {}), - "status_mapping": dependency_data.get("status_mapping", {}), - "creation_hierarchy": dependency_data.get("creation_hierarchy", {}), - "providers": {name: provider.model_dump() for name, provider in schema.providers.items()}, - } - return BacklogConfigModel.model_validate(config_payload).model_dump() - - @beartype - @ensure(lambda result: isinstance(result, dict), "Merged config must be dict") - def _merge_config(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: - merged = dict(base) - for key in ("template", "project_key"): - value = override.get(key) - if value is not None: - merged[key] = value - for key in ("type_mapping", "dependency_rules", "status_mapping", "creation_hierarchy", "providers"): - merged[key] = {**merged.get(key, {}), **override.get(key, {})} - return merged - - @beartype - @require(lambda raw_items: isinstance(raw_items, list), "raw_items must be a list") - def add_items(self, raw_items: list[dict[str, Any]]) -> BacklogGraphBuilder: - for raw_item in raw_items: - item_id = str(raw_item.get("id") or raw_item.get("key") or "") - if not item_id: - continue - inferred_type, confidence = self._infer_type(raw_item) - status_value = self._map_status(str(raw_item.get("status") or raw_item.get("state") or "unknown")) - item = BacklogItem( - id=item_id, - key=str(raw_item.get("key") or item_id), - title=str(raw_item.get("title") or raw_item.get("name") or item_id), - type=inferred_type, - status=status_value, - description=(str(raw_item.get("description")) if raw_item.get("description") else None), - priority=(str(raw_item.get("priority")) if raw_item.get("priority") else None), - parent_id=(str(raw_item.get("parent_id")) if raw_item.get("parent_id") else None), - raw_data=raw_item, - inferred_type=inferred_type, - confidence=confidence, - ) - self._items[item.id] = item - return self - - @beartype - @ensure(lambda result: isinstance(result, tuple), "Type inference must return tuple") - def _infer_type(self, raw_item: dict[str, Any]) -> tuple[ItemType, float]: - raw_type = str(raw_item.get("type") or raw_item.get("work_item_type") or "custom").strip().lower() - mapping = { - **{k.lower(): v for k, v in self._template.get("type_mapping", {}).items()}, - **{k.lower(): v for k, v in self._custom_config.get("type_mapping", {}).items()}, - } - mapped = mapping.get(raw_type, "custom") - try: - return ItemType(mapped), 0.9 if mapped != "custom" else 0.4 - except ValueError: - return ItemType.CUSTOM, 0.1 - - @beartype - @ensure(lambda result: isinstance(result, str), "Mapped status must be a string") - def _map_status(self, status: str) -> str: - normalized = status.strip().lower() - mapping = { - **{k.lower(): v for k, v in self._template.get("status_mapping", {}).items()}, - **{k.lower(): v for k, v in self._custom_config.get("status_mapping", {}).items()}, - } - return str(mapping.get(normalized, normalized or "unknown")) - - @beartype - @require(lambda relationships: isinstance(relationships, list), "relationships must be a list") - def add_dependencies(self, relationships: list[dict[str, Any]]) -> BacklogGraphBuilder: - for relationship in relationships: - source_id = str(relationship.get("source_id") or relationship.get("from") or "") - target_id = str(relationship.get("target_id") or relationship.get("to") or "") - if not source_id or not target_id: - continue - dep_type = self._infer_dependency_type(str(relationship.get("type") or relationship.get("relation") or "")) - self._dependencies.append( - Dependency( - source_id=source_id, - target_id=target_id, - type=dep_type, - metadata=relationship, - confidence=0.9 if dep_type is not DependencyType.CUSTOM else 0.4, - ) - ) - return self - - @beartype - @ensure(lambda result: isinstance(result, DependencyType), "Dependency type must be normalized") - def _infer_dependency_type(self, raw_relationship_type: str) -> DependencyType: - normalized = raw_relationship_type.strip().lower() - mapping = { - **{k.lower(): v for k, v in self._template.get("dependency_rules", {}).items()}, - **{k.lower(): v for k, v in self._custom_config.get("dependency_rules", {}).items()}, - } - mapped = mapping.get(normalized) - if mapped is None: - return DependencyType.CUSTOM - try: - return DependencyType(mapped) - except ValueError: - return DependencyType.CUSTOM - - def _compute_transitive_closure(self) -> dict[str, list[str]]: - adjacency: dict[str, set[str]] = defaultdict(set) - for dep in self._dependencies: - adjacency[dep.source_id].add(dep.target_id) - - closure: dict[str, list[str]] = {} - for source in self._items: - seen: set[str] = set() - stack = list(adjacency.get(source, set())) - while stack: - node = stack.pop() - if node in seen: - continue - seen.add(node) - stack.extend(adjacency.get(node, set())) - if seen: - closure[source] = sorted(seen) - return closure - - def _detect_cycles(self) -> list[list[str]]: - adjacency: dict[str, list[str]] = defaultdict(list) - for dep in self._dependencies: - adjacency[dep.source_id].append(dep.target_id) - - visited: set[str] = set() - in_stack: set[str] = set() - path: list[str] = [] - cycles: list[list[str]] = [] - - def dfs(node: str) -> None: - visited.add(node) - in_stack.add(node) - path.append(node) - for neighbor in adjacency.get(node, []): - if neighbor not in visited: - dfs(neighbor) - elif neighbor in in_stack: - cycle_start = path.index(neighbor) - cycles.append([*path[cycle_start:], neighbor]) - path.pop() - in_stack.remove(node) - - for node in self._items: - if node not in visited: - dfs(node) - return cycles - - def _find_orphans(self) -> list[str]: - targets = {dep.target_id for dep in self._dependencies} - orphans: list[str] = [] - for item in self._items.values(): - if item.parent_id: - continue - if item.id not in targets: - orphans.append(item.id) - return sorted(set(orphans)) - - @beartype - @ensure(lambda result: isinstance(result, BacklogGraph), "Builder must return BacklogGraph") - def build(self) -> BacklogGraph: - return BacklogGraph( - items=self._items, - dependencies=self._dependencies, - provider=self.provider, - project_key=str(self._custom_config.get("project_key") or "unknown"), - transitive_closure=self._compute_transitive_closure(), - cycles_detected=self._detect_cycles(), - orphans=self._find_orphans(), - ) diff --git a/modules/backlog-core/src/backlog_core/graph/config_schema.py b/modules/backlog-core/src/backlog_core/graph/config_schema.py deleted file mode 100644 index 50a12ea5..00000000 --- a/modules/backlog-core/src/backlog_core/graph/config_schema.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Backlog graph configuration schema and loading helpers.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -import yaml -from pydantic import BaseModel, Field - - -class DependencyConfig(BaseModel): - """Dependency mapping configuration.""" - - template: str | None = Field(default=None, description="Selected mapping template") - type_mapping: dict[str, str] = Field(default_factory=dict, description="Raw type -> normalized type mapping") - dependency_rules: dict[str, str] = Field(default_factory=dict, description="Raw relation -> normalized mapping") - status_mapping: dict[str, str] = Field(default_factory=dict, description="Raw status -> normalized status mapping") - creation_hierarchy: dict[str, list[str]] = Field( - default_factory=dict, - description="Allowed parent types per child type", - ) - - -class ProviderConfig(BaseModel): - """Provider-specific backlog connection configuration.""" - - adapter: str | None = Field(default=None, description="Adapter identifier (github, ado, jira, ...) ") - project_id: str | None = Field(default=None, description="Project ID/repo/board identifier") - settings: dict[str, Any] = Field(default_factory=dict, description="Provider-specific extra settings") - - -class DevOpsStageConfig(BaseModel): - """Stage-level workflow defaults for integrated DevOps commands.""" - - default_action: str | None = Field(default=None, description="Default action name for the stage") - enabled: bool = Field(default=True, description="Whether stage is enabled") - settings: dict[str, Any] = Field(default_factory=dict, description="Optional stage-specific settings") - - -class BacklogConfigSchema(BaseModel): - """Project-level backlog configuration schema.""" - - dependencies: DependencyConfig = Field(default_factory=DependencyConfig) - providers: dict[str, ProviderConfig] = Field(default_factory=dict) - devops_stages: dict[str, DevOpsStageConfig] = Field(default_factory=dict) - - -def _load_backlog_config_from_yaml(path: Path) -> BacklogConfigSchema | None: - """Load and validate backlog config payload from a YAML file path.""" - if not path.exists(): - return None - - loaded = yaml.safe_load(path.read_text(encoding="utf-8")) - if not isinstance(loaded, dict): - return None - - backlog_config = loaded.get("backlog_config") - if not isinstance(backlog_config, dict): - return None - - payload = dict(backlog_config) - devops_stages = loaded.get("devops_stages") - if isinstance(devops_stages, dict): - payload["devops_stages"] = devops_stages - - return BacklogConfigSchema.model_validate(payload) - - -def load_backlog_config_from_spec(spec_path: Path) -> BacklogConfigSchema | None: - """Load backlog config from `.specfact/spec.yaml` if present and valid.""" - return _load_backlog_config_from_yaml(spec_path) - - -def load_backlog_config_from_backlog_file(config_path: Path) -> BacklogConfigSchema | None: - """Load backlog config from `.specfact/backlog-config.yaml` if present and valid.""" - return _load_backlog_config_from_yaml(config_path) diff --git a/modules/backlog-core/src/backlog_core/graph/models.py b/modules/backlog-core/src/backlog_core/graph/models.py deleted file mode 100644 index 45fde5fe..00000000 --- a/modules/backlog-core/src/backlog_core/graph/models.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Provider-agnostic backlog dependency graph models.""" - -from __future__ import annotations - -from datetime import UTC, datetime -from enum import StrEnum -from typing import Any - -from pydantic import BaseModel, Field - - -class ItemType(StrEnum): - """Normalized backlog item types.""" - - EPIC = "epic" - FEATURE = "feature" - STORY = "story" - TASK = "task" - BUG = "bug" - SUB_TASK = "sub_task" - CUSTOM = "custom" - - -class DependencyType(StrEnum): - """Normalized dependency relationship types.""" - - PARENT_CHILD = "parent_child" - BLOCKS = "blocks" - RELATES_TO = "relates_to" - DUPLICATES = "duplicates" - CLONED_FROM = "cloned_from" - IMPLEMENTS = "implements" - CUSTOM = "custom" - - -class BacklogItem(BaseModel): - """Unified backlog item node for provider-agnostic graph analysis.""" - - id: str = Field(..., description="Provider-specific unique item id") - key: str | None = Field(default=None, description="Display key, e.g. ABC-123") - title: str = Field(..., description="Backlog item title") - type: ItemType = Field(..., description="Canonical normalized item type") - status: str = Field(default="unknown", description="Provider status value") - description: str | None = Field(default=None, description="Backlog item body/description") - priority: str | None = Field(default=None, description="Provider priority value") - parent_id: str | None = Field(default=None, description="Parent item id when provided") - raw_data: dict[str, Any] = Field(default_factory=dict, description="Raw provider payload for lossless round-trip") - inferred_type: ItemType | None = Field(default=None, description="Type inferred from template/rules") - confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="Inference confidence score") - - def effective_type(self) -> ItemType: - """Return inferred type only when confidence is high enough.""" - if self.inferred_type is not None and self.confidence >= 0.5: - return self.inferred_type - return self.type - - -class Dependency(BaseModel): - """Directed edge between backlog items.""" - - source_id: str = Field(..., description="Source item id") - target_id: str = Field(..., description="Target item id") - type: DependencyType = Field(..., description="Normalized dependency type") - metadata: dict[str, Any] = Field(default_factory=dict, description="Additional provider relationship fields") - confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="Relationship confidence score") - - -class BacklogGraph(BaseModel): - """Provider-agnostic dependency graph for backlog analytics.""" - - items: dict[str, BacklogItem] = Field(default_factory=dict, description="Graph nodes keyed by item id") - dependencies: list[Dependency] = Field(default_factory=list, description="Graph edges") - provider: str = Field(..., description="Provider name") - project_key: str = Field(..., description="Project/repo identifier") - fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC), description="Graph fetch timestamp") - transitive_closure: dict[str, list[str]] = Field( - default_factory=dict, - description="Computed transitive dependencies by source id", - ) - cycles_detected: list[list[str]] = Field(default_factory=list, description="Detected cycles") - orphans: list[str] = Field(default_factory=list, description="Orphan item ids") - - def to_json(self) -> str: - """Serialize graph to JSON.""" - return self.model_dump_json(indent=2) - - @classmethod - def from_json(cls, payload: str) -> BacklogGraph: - """Deserialize graph from JSON payload.""" - return cls.model_validate_json(payload) diff --git a/modules/backlog-core/src/backlog_core/main.py b/modules/backlog-core/src/backlog_core/main.py deleted file mode 100644 index a7c3f2a1..00000000 --- a/modules/backlog-core/src/backlog_core/main.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Backlog core Typer apps.""" - -from __future__ import annotations - -import click -import typer -from typer.core import TyperGroup - -from backlog_core.commands import ( - add, - analyze_deps, - diff, - generate_release_notes, - promote, - sync, - trace_impact, - verify_readiness, -) -from backlog_core.commands.delta import delta_app as _delta_app - - -class _BacklogCoreCommandGroup(TyperGroup): - """Impact-oriented ordering for backlog-core commands.""" - - _ORDER_PRIORITY: dict[str, int] = { - # Command groups first for discoverability. - "delta": 10, - # High-impact flow commands next. - "add": 20, - "sync": 30, - "verify-readiness": 40, - "analyze-deps": 50, - "diff": 60, - "promote": 70, - "generate-release-notes": 80, - "trace-impact": 90, - } - - def list_commands(self, ctx: click.Context) -> list[str]: - commands = list(super().list_commands(ctx)) - return sorted(commands, key=lambda name: (self._ORDER_PRIORITY.get(name, 1000), name)) - - -backlog_app = typer.Typer( - name="backlog", - help="Backlog dependency analysis and sync", - cls=_BacklogCoreCommandGroup, -) -backlog_app.command("add")(add) -backlog_app.command("analyze-deps")(analyze_deps) -backlog_app.command("trace-impact")(trace_impact) -backlog_app.command("sync")(sync) -backlog_app.command("diff")(diff) -backlog_app.command("promote")(promote) -backlog_app.command("verify-readiness")(verify_readiness) -backlog_app.command("generate-release-notes")(generate_release_notes) -backlog_app.add_typer(_delta_app, name="delta", help="Backlog delta analysis and impact tracking") - -# Backward-compatible module package loader expects an `app` attribute. -app = backlog_app diff --git a/modules/backlog-core/src/backlog_core/resources/backlog-templates/ado_safe.yaml b/modules/backlog-core/src/backlog_core/resources/backlog-templates/ado_safe.yaml deleted file mode 100644 index 912577fa..00000000 --- a/modules/backlog-core/src/backlog_core/resources/backlog-templates/ado_safe.yaml +++ /dev/null @@ -1,14 +0,0 @@ -type_mapping: - portfolio epic: epic - feature: feature - story: story - task: task - bug: bug -dependency_rules: - predecessor: blocks - successor: blocks - parent: parent_child -status_mapping: - funnel: backlog - implementing: in_progress - done: done diff --git a/modules/backlog-core/src/backlog_core/resources/backlog-templates/ado_scrum.yaml b/modules/backlog-core/src/backlog_core/resources/backlog-templates/ado_scrum.yaml deleted file mode 100644 index 52422d92..00000000 --- a/modules/backlog-core/src/backlog_core/resources/backlog-templates/ado_scrum.yaml +++ /dev/null @@ -1,15 +0,0 @@ -type_mapping: - epic: epic - feature: feature - user story: story - task: task - bug: bug -dependency_rules: - parent: parent_child - child: parent_child - blocks: blocks - relates: relates_to -status_mapping: - new: todo - active: in_progress - closed: done diff --git a/modules/backlog-core/src/backlog_core/resources/backlog-templates/github_custom.yaml b/modules/backlog-core/src/backlog_core/resources/backlog-templates/github_custom.yaml deleted file mode 100644 index a8c99127..00000000 --- a/modules/backlog-core/src/backlog_core/resources/backlog-templates/github_custom.yaml +++ /dev/null @@ -1,22 +0,0 @@ -type_mapping: - epic: epic - feature: feature - story: story - task: task - bug: bug -creation_hierarchy: - epic: [] - feature: [epic] - story: [feature, epic] - task: [story, feature] - bug: [story, feature, epic] -dependency_rules: - blocks: blocks - blocked_by: blocks - relates: relates_to -status_mapping: - open: todo - closed: done - todo: todo - in progress: in_progress - done: done diff --git a/modules/backlog-core/src/backlog_core/resources/backlog-templates/github_projects.yaml b/modules/backlog-core/src/backlog_core/resources/backlog-templates/github_projects.yaml deleted file mode 100644 index baf46c5b..00000000 --- a/modules/backlog-core/src/backlog_core/resources/backlog-templates/github_projects.yaml +++ /dev/null @@ -1,16 +0,0 @@ -type_mapping: - epic: epic - feature: feature - story: story - task: task - bug: bug -dependency_rules: - blocks: blocks - blocked_by: blocks - relates: relates_to -status_mapping: - open: todo - closed: done - todo: todo - in progress: in_progress - done: done diff --git a/modules/backlog-core/src/backlog_core/resources/backlog-templates/jira_kanban.yaml b/modules/backlog-core/src/backlog_core/resources/backlog-templates/jira_kanban.yaml deleted file mode 100644 index 7ba0dac6..00000000 --- a/modules/backlog-core/src/backlog_core/resources/backlog-templates/jira_kanban.yaml +++ /dev/null @@ -1,13 +0,0 @@ -type_mapping: - epic: epic - story: story - task: task - bug: bug -dependency_rules: - blocks: blocks - is blocked by: blocks - relates to: relates_to -status_mapping: - to do: todo - in progress: in_progress - done: done diff --git a/modules/backlog-core/tests/unit/test_adapter_create_issue.py b/modules/backlog-core/tests/unit/test_adapter_create_issue.py deleted file mode 100644 index bf048d6e..00000000 --- a/modules/backlog-core/tests/unit/test_adapter_create_issue.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Unit tests for backlog adapter create_issue contract.""" - -from __future__ import annotations - -import sys -from pathlib import Path - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[4] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) -sys.path.insert(0, str(REPO_ROOT / "src")) - -from specfact_cli.adapters.ado import AdoAdapter -from specfact_cli.adapters.github import GitHubAdapter - - -class _DummyResponse: - def __init__(self, payload: dict) -> None: - self._payload = payload - self.status_code = 201 - self.ok = True - self.text = "" - - def raise_for_status(self) -> None: - return None - - def json(self) -> dict: - return self._payload - - -def test_github_create_issue_maps_payload_and_returns_shape(monkeypatch) -> None: - """GitHub create_issue sends issue payload and normalizes response fields.""" - adapter = GitHubAdapter(repo_owner="nold-ai", repo_name="specfact-cli", api_token="token", use_gh_cli=False) - - captured: dict = {} - - def _fake_post(url: str, json: dict, headers: dict, timeout: int): - captured["url"] = url - captured["json"] = json - captured["headers"] = headers - captured["timeout"] = timeout - return _DummyResponse({"id": 77, "number": 42, "html_url": "https://github.com/nold-ai/specfact-cli/issues/42"}) - - import specfact_cli.adapters.github as github_module - - monkeypatch.setattr(github_module.requests, "post", _fake_post) - - retry_call: dict[str, object] = {} - - def _capture_retry(request_callable, **kwargs): - retry_call.update(kwargs) - return request_callable() - - monkeypatch.setattr(adapter, "_request_with_retry", _capture_retry) - - result = adapter.create_issue( - "nold-ai/specfact-cli", - { - "type": "story", - "title": "Implement X", - "description": "Acceptance criteria: ...", - "acceptance_criteria": "Given/When/Then", - "priority": "high", - "story_points": 5, - "parent_id": "100", - }, - ) - - assert retry_call.get("retry_on_ambiguous_transport") is False - assert captured["url"].endswith("/repos/nold-ai/specfact-cli/issues") - assert captured["json"]["title"] == "Implement X" - labels = [label.lower() for label in captured["json"]["labels"]] - assert "story" in labels - assert "priority:high" in labels - assert "story-points:5" in labels - assert "acceptance criteria" in captured["json"]["body"].lower() - assert result == {"id": "42", "key": "42", "url": "https://github.com/nold-ai/specfact-cli/issues/42"} - - -def test_ado_create_issue_maps_payload_and_parent_relation(monkeypatch) -> None: - """ADO create_issue sends JSON patch and includes parent relation when provided.""" - adapter = AdoAdapter(org="nold-ai", project="specfact-cli", api_token="token") - - captured: dict = {} - - def _fake_patch(url: str, json: list, headers: dict, timeout: int): - captured["url"] = url - captured["json"] = json - captured["headers"] = headers - captured["timeout"] = timeout - return _DummyResponse( - { - "id": 901, - "url": "https://dev.azure.com/nold-ai/specfact-cli/_apis/wit/workItems/901", - "_links": { - "html": {"href": "https://dev.azure.com/nold-ai/specfact-cli/_workitems/edit/901"}, - }, - } - ) - - import specfact_cli.adapters.ado as ado_module - - monkeypatch.setattr(ado_module.requests, "patch", _fake_patch) - - retry_call: dict[str, object] = {} - - def _capture_retry(request_callable, **kwargs): - retry_call.update(kwargs) - return request_callable() - - monkeypatch.setattr(adapter, "_request_with_retry", _capture_retry) - - result = adapter.create_issue( - "nold-ai/specfact-cli", - { - "type": "story", - "title": "Implement X", - "description": "Acceptance criteria: ...", - "acceptance_criteria": "Given/When/Then", - "priority": 1, - "story_points": 8, - "sprint": "Project\\Release 1\\Sprint 3", - "parent_id": "123", - "description_format": "classic", - }, - ) - - assert retry_call.get("retry_on_ambiguous_transport") is False - assert "/_apis/wit/workitems/$" in captured["url"] - assert any(op.get("path") == "/fields/System.Title" and op.get("value") == "Implement X" for op in captured["json"]) - assert any(op.get("path") == "/relations/-" for op in captured["json"]) - assert any( - op.get("path") == "/multilineFieldsFormat/System.Description" and op.get("value") == "Html" - for op in captured["json"] - ) - assert any(op.get("path") == "/fields/Microsoft.VSTS.Common.AcceptanceCriteria" for op in captured["json"]) - assert any( - op.get("path") == "/fields/Microsoft.VSTS.Common.Priority" and op.get("value") == 1 for op in captured["json"] - ) - assert any( - op.get("path") == "/fields/Microsoft.VSTS.Scheduling.StoryPoints" and op.get("value") == 8 - for op in captured["json"] - ) - assert any( - op.get("path") == "/fields/System.IterationPath" and op.get("value") == "Project\\Release 1\\Sprint 3" - for op in captured["json"] - ) - assert result == { - "id": "901", - "key": "901", - "url": "https://dev.azure.com/nold-ai/specfact-cli/_workitems/edit/901", - } - - -def test_github_create_issue_sets_projects_type_field_when_configured(monkeypatch) -> None: - """GitHub create_issue can set ProjectV2 Type field when config is provided.""" - adapter = GitHubAdapter(repo_owner="nold-ai", repo_name="specfact-cli", api_token="token", use_gh_cli=False) - - calls: list[tuple[str, dict]] = [] - - def _fake_post(url: str, json: dict, headers: dict, timeout: int): - _ = headers, timeout - calls.append((url, json)) - if url.endswith("/issues"): - return _DummyResponse( - { - "id": 88, - "number": 55, - "node_id": "ISSUE_NODE_55", - "html_url": "https://github.com/nold-ai/specfact-cli/issues/55", - } - ) - if url.endswith("/graphql"): - query = str(json.get("query") or "") - if "addProjectV2ItemById" in query: - return _DummyResponse({"data": {"addProjectV2ItemById": {"item": {"id": "PVT_ITEM_1"}}}}) - if "updateProjectV2ItemFieldValue" in query: - return _DummyResponse( - {"data": {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "PVT_ITEM_1"}}}} - ) - return _DummyResponse({"data": {}}) - raise AssertionError(f"Unexpected URL: {url}") - - import specfact_cli.adapters.github as github_module - - monkeypatch.setattr(github_module.requests, "post", _fake_post) - - result = adapter.create_issue( - "nold-ai/specfact-cli", - { - "type": "story", - "title": "Implement projects type", - "description": "Body", - "provider_fields": { - "github_project_v2": { - "project_id": "PVT_PROJECT_1", - "type_field_id": "PVT_FIELD_TYPE", - "type_option_ids": { - "story": "PVT_OPTION_STORY", - }, - } - }, - }, - ) - - graphql_calls = [entry for entry in calls if entry[0].endswith("/graphql")] - assert len(graphql_calls) == 2 - - add_variables = graphql_calls[0][1]["variables"] - assert add_variables == {"projectId": "PVT_PROJECT_1", "contentId": "ISSUE_NODE_55"} - - set_variables = graphql_calls[1][1]["variables"] - assert set_variables["projectId"] == "PVT_PROJECT_1" - assert set_variables["itemId"] == "PVT_ITEM_1" - assert set_variables["fieldId"] == "PVT_FIELD_TYPE" - assert set_variables["optionId"] == "PVT_OPTION_STORY" - - assert result == {"id": "55", "key": "55", "url": "https://github.com/nold-ai/specfact-cli/issues/55"} - - -def test_github_create_issue_sets_repository_issue_type_when_configured(monkeypatch) -> None: - """GitHub create_issue sets repository issue Type when mapping is configured.""" - adapter = GitHubAdapter(repo_owner="nold-ai", repo_name="specfact-cli", api_token="token", use_gh_cli=False) - - calls: list[tuple[str, dict]] = [] - - def _fake_post(url: str, json: dict, headers: dict, timeout: int): - _ = headers, timeout - calls.append((url, json)) - if url.endswith("/issues"): - return _DummyResponse( - { - "id": 188, - "number": 77, - "node_id": "ISSUE_NODE_77", - "html_url": "https://github.com/nold-ai/specfact-cli/issues/77", - } - ) - if url.endswith("/graphql"): - query = str(json.get("query") or "") - if "updateIssue(input:" in query: - return _DummyResponse({"data": {"updateIssue": {"issue": {"id": "ISSUE_NODE_77"}}}}) - return _DummyResponse({"data": {}}) - raise AssertionError(f"Unexpected URL: {url}") - - import specfact_cli.adapters.github as github_module - - monkeypatch.setattr(github_module.requests, "post", _fake_post) - - result = adapter.create_issue( - "nold-ai/specfact-cli", - { - "type": "task", - "title": "Apply issue type", - "description": "Body", - "provider_fields": { - "github_issue_types": { - "type_ids": { - "task": "IT_kwDODWwjB84Brk47", - } - } - }, - }, - ) - - graphql_calls = [entry for entry in calls if entry[0].endswith("/graphql")] - assert len(graphql_calls) == 1 - variables = graphql_calls[0][1]["variables"] - assert variables == {"issueId": "ISSUE_NODE_77", "issueTypeId": "IT_kwDODWwjB84Brk47"} - assert result == {"id": "77", "key": "77", "url": "https://github.com/nold-ai/specfact-cli/issues/77"} - - -def test_github_create_issue_links_native_parent_subissue(monkeypatch) -> None: - """GitHub create_issue links parent relationship via native sidebar sub-issue mutation.""" - adapter = GitHubAdapter(repo_owner="nold-ai", repo_name="specfact-cli", api_token="token", use_gh_cli=False) - - calls: list[tuple[str, dict]] = [] - - def _fake_post(url: str, json: dict, headers: dict, timeout: int): - _ = headers, timeout - calls.append((url, json)) - if url.endswith("/issues"): - return _DummyResponse( - { - "id": 288, - "number": 99, - "node_id": "ISSUE_NODE_99", - "html_url": "https://github.com/nold-ai/specfact-cli/issues/99", - } - ) - if url.endswith("/graphql"): - query = str(json.get("query") or "") - if "repository(owner:$owner, name:$repo)" in query and "issue(number:$number)" in query: - return _DummyResponse({"data": {"repository": {"issue": {"id": "ISSUE_NODE_PARENT_11"}}}}) - if "addSubIssue(input:" in query: - return _DummyResponse( - { - "data": { - "addSubIssue": { - "issue": {"id": "ISSUE_NODE_PARENT_11"}, - "subIssue": {"id": "ISSUE_NODE_99"}, - } - } - } - ) - return _DummyResponse({"data": {}}) - raise AssertionError(f"Unexpected URL: {url}") - - import specfact_cli.adapters.github as github_module - - monkeypatch.setattr(github_module.requests, "post", _fake_post) - - result = adapter.create_issue( - "nold-ai/specfact-cli", - { - "type": "task", - "title": "Link native parent", - "description": "Body", - "parent_id": "11", - }, - ) - - graphql_calls = [entry for entry in calls if entry[0].endswith("/graphql")] - assert len(graphql_calls) == 2 - - lookup_variables = graphql_calls[0][1]["variables"] - assert lookup_variables == {"owner": "nold-ai", "repo": "specfact-cli", "number": 11} - - link_variables = graphql_calls[1][1]["variables"] - assert link_variables == {"parentIssueId": "ISSUE_NODE_PARENT_11", "subIssueId": "ISSUE_NODE_99"} - assert result == {"id": "99", "key": "99", "url": "https://github.com/nold-ai/specfact-cli/issues/99"} diff --git a/modules/backlog-core/tests/unit/test_add_command.py b/modules/backlog-core/tests/unit/test_add_command.py deleted file mode 100644 index 000744fc..00000000 --- a/modules/backlog-core/tests/unit/test_add_command.py +++ /dev/null @@ -1,1031 +0,0 @@ -"""Unit tests for backlog add interactive issue creation command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import yaml -from typer.testing import CliRunner - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[4] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) -sys.path.insert(0, str(REPO_ROOT / "src")) - -from backlog_core.commands.add import _has_github_repo_issue_type_mapping -from backlog_core.main import backlog_app - - -runner = CliRunner() - - -class _FakeAdapter: - def __init__(self, items: list[dict], relationships: list[dict], created: list[dict]) -> None: - self._items = items - self._relationships = relationships - self.created = created - - def fetch_all_issues(self, project_id: str, filters: dict | None = None) -> list[dict]: - _ = project_id, filters - return self._items - - def fetch_relationships(self, project_id: str) -> list[dict]: - _ = project_id - return self._relationships - - def create_issue(self, project_id: str, payload: dict) -> dict: - _ = project_id - self.created.append(payload) - return {"id": "123", "key": "123", "url": "https://example.test/issues/123"} - - -def test_has_github_repo_issue_type_mapping_story_fallback_to_feature() -> None: - """When story is unavailable but feature exists, mapping should still be considered available.""" - provider_fields = {"github_issue_types": {"type_ids": {"feature": "IT_FEATURE_ID"}}} - assert _has_github_repo_issue_type_mapping(provider_fields, "story") is True - - -def test_has_github_repo_issue_type_mapping_story_missing_without_feature() -> None: - """When both story and feature are unavailable, mapping is unavailable.""" - provider_fields = {"github_issue_types": {"type_ids": {"bug": "IT_BUG_ID"}}} - assert _has_github_repo_issue_type_mapping(provider_fields, "story") is False - - -def test_backlog_add_non_interactive_requires_type_and_title(monkeypatch) -> None: - """Non-interactive add fails when required options are missing.""" - from specfact_cli.adapters.registry import AdapterRegistry - - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: _FakeAdapter([], [], [])) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--non-interactive", - ], - ) - - assert result.exit_code == 1 - assert "required in --non-interactive mode" in result.stdout - - -def test_backlog_add_validates_missing_parent(monkeypatch) -> None: - """Add fails when provided parent key/id cannot be resolved.""" - from specfact_cli.adapters.registry import AdapterRegistry - - adapter = _FakeAdapter(items=[], relationships=[], created=[]) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--type", - "story", - "--parent", - "FEAT-123", - "--title", - "Implement X", - "--non-interactive", - ], - ) - - assert result.exit_code == 1 - assert "Parent 'FEAT-123' not found" in result.stdout - - -def test_backlog_add_uses_default_hierarchy_when_no_github_custom_mapping_file(monkeypatch, tmp_path: Path) -> None: - """Add falls back to default hierarchy when github_custom mapping file is absent.""" - from specfact_cli.adapters.registry import AdapterRegistry - - items = [ - { - "id": "42", - "key": "STORY-1", - "title": "Story Parent", - "type": "story", - "status": "todo", - } - ] - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=items, relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - monkeypatch.chdir(tmp_path) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--type", - "task", - "--parent", - "STORY-1", - "--title", - "Implement X", - "--body", - "Body", - "--non-interactive", - ], - ) - - assert result.exit_code == 0 - assert created_payloads - - -def test_backlog_add_auto_applies_github_custom_mapping_file(monkeypatch, tmp_path: Path) -> None: - """Add automatically loads .specfact github_custom mapping file when present.""" - from specfact_cli.adapters.registry import AdapterRegistry - - custom_mapping_file = tmp_path / ".specfact" / "templates" / "backlog" / "field_mappings" / "github_custom.yaml" - custom_mapping_file.parent.mkdir(parents=True, exist_ok=True) - custom_mapping_file.write_text( - """ -creation_hierarchy: - task: [epic] -""".strip(), - encoding="utf-8", - ) - - items = [ - { - "id": "42", - "key": "STORY-1", - "title": "Story Parent", - "type": "story", - "status": "todo", - } - ] - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=items, relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - monkeypatch.chdir(tmp_path) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--type", - "task", - "--parent", - "STORY-1", - "--title", - "Implement X", - "--body", - "Body", - "--non-interactive", - ], - ) - - assert result.exit_code == 1 - assert "Type 'task' is not allowed under parent type 'story'" in result.stdout - - -def test_backlog_add_honors_creation_hierarchy_from_custom_config(monkeypatch, tmp_path: Path) -> None: - """Add validates child->parent relationship using explicit hierarchy config.""" - from specfact_cli.adapters.registry import AdapterRegistry - - config_file = tmp_path / "custom.yaml" - config_file.write_text( - """ -creation_hierarchy: - story: [feature] -""".strip(), - encoding="utf-8", - ) - - items = [ - { - "id": "42", - "key": "FEAT-123", - "title": "Parent", - "type": "feature", - "status": "todo", - } - ] - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=items, relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--template", - "github_projects", - "--custom-config", - str(config_file), - "--type", - "story", - "--parent", - "FEAT-123", - "--title", - "Implement X", - "--body", - "Acceptance criteria: ...", - "--non-interactive", - ], - ) - - assert result.exit_code == 0 - assert created_payloads and created_payloads[0]["parent_id"] == "42" - - -def test_backlog_add_ado_requires_saved_required_custom_field(monkeypatch, tmp_path: Path) -> None: - """Add should fail before adapter create when saved metadata marks a custom ADO field as required.""" - from specfact_cli.adapters.registry import AdapterRegistry - - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=[], relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - monkeypatch.chdir(tmp_path) - - spec_dir = tmp_path / ".specfact" - spec_dir.mkdir(parents=True, exist_ok=True) - (spec_dir / "backlog-config.yaml").write_text( - yaml.safe_dump( - { - "backlog_config": { - "providers": { - "ado": { - "adapter": "ado", - "project_id": "test-org/test-project", - "settings": { - "selected_work_item_type": "User Story", - "field_mapping_file": ".specfact/templates/backlog/field_mappings/ado_custom.yaml", - "required_fields_by_work_item_type": {"User Story": ["Custom.FinOpsCategory"]}, - "allowed_values_by_work_item_type": { - "User Story": {"Custom.FinOpsCategory": ["Business", "Compliance"]} - }, - }, - } - } - } - } - ), - encoding="utf-8", - ) - mapping_file = spec_dir / "templates" / "backlog" / "field_mappings" / "ado_custom.yaml" - mapping_file.parent.mkdir(parents=True, exist_ok=True) - mapping_file.write_text( - yaml.safe_dump({"field_mappings": {"Custom.FinOpsCategory": "finops_category"}}), - encoding="utf-8", - ) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "test-org/test-project", - "--adapter", - "ado", - "--type", - "story", - "--title", - "Implement finops guardrail", - "--body", - "Body", - "--non-interactive", - ], - ) - - assert result.exit_code == 1 - assert "missing required custom field" in result.stdout.lower() - assert "finops_category" in result.stdout.lower() - assert not created_payloads - - -def test_backlog_add_ado_validates_and_forwards_custom_fields(monkeypatch, tmp_path: Path) -> None: - """Add should validate picklist values and forward resolved ADO custom field payload.""" - from specfact_cli.adapters.registry import AdapterRegistry - - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=[], relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - monkeypatch.chdir(tmp_path) - - spec_dir = tmp_path / ".specfact" - spec_dir.mkdir(parents=True, exist_ok=True) - (spec_dir / "backlog-config.yaml").write_text( - yaml.safe_dump( - { - "backlog_config": { - "providers": { - "ado": { - "adapter": "ado", - "project_id": "test-org/test-project", - "settings": { - "selected_work_item_type": "User Story", - "field_mapping_file": ".specfact/templates/backlog/field_mappings/ado_custom.yaml", - "required_fields_by_work_item_type": {"User Story": ["Custom.FinOpsCategory"]}, - "allowed_values_by_work_item_type": { - "User Story": {"Custom.FinOpsCategory": ["Business", "Compliance"]} - }, - }, - } - } - } - } - ), - encoding="utf-8", - ) - mapping_file = spec_dir / "templates" / "backlog" / "field_mappings" / "ado_custom.yaml" - mapping_file.parent.mkdir(parents=True, exist_ok=True) - mapping_file.write_text( - yaml.safe_dump({"field_mappings": {"Custom.FinOpsCategory": "finops_category"}}), - encoding="utf-8", - ) - - invalid_result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "test-org/test-project", - "--adapter", - "ado", - "--type", - "story", - "--title", - "Implement finops guardrail", - "--body", - "Body", - "--custom-field", - "finops_category=Wrong", - "--non-interactive", - ], - ) - - assert invalid_result.exit_code == 1 - assert "allowed values" in invalid_result.stdout.lower() - assert "business" in invalid_result.stdout.lower() - assert "compliance" in invalid_result.stdout.lower() - - created_payloads.clear() - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "test-org/test-project", - "--adapter", - "ado", - "--type", - "story", - "--title", - "Implement finops guardrail", - "--body", - "Body", - "--custom-field", - "finops_category=Business", - "--non-interactive", - ], - ) - - assert result.exit_code == 0, result.stdout - assert created_payloads - assert created_payloads[0]["provider_fields"]["fields"]["Custom.FinOpsCategory"] == "Business" - - -def test_backlog_add_check_dor_blocks_invalid_draft(monkeypatch, tmp_path: Path) -> None: - """Add fails DoR check when configured required fields are missing.""" - from specfact_cli.adapters.registry import AdapterRegistry - - dor_dir = tmp_path / ".specfact" - dor_dir.mkdir(parents=True, exist_ok=True) - (dor_dir / "dor.yaml").write_text( - """ -rules: - acceptance_criteria: true -""".strip(), - encoding="utf-8", - ) - - adapter = _FakeAdapter(items=[], relationships=[], created=[]) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--type", - "story", - "--title", - "Implement X", - "--body", - "No explicit section", - "--non-interactive", - "--check-dor", - "--repo-path", - str(tmp_path), - ], - ) - - assert result.exit_code == 1 - assert "Definition of Ready" in result.stdout - - -class _FakeAdoAdapter(_FakeAdapter): - def _get_current_iteration(self) -> str | None: - return "Project\\Sprint 42" - - def _list_available_iterations(self) -> list[str]: - return ["Project\\Sprint 41", "Project\\Sprint 42"] - - -def test_backlog_add_interactive_multiline_body_uses_end_marker(monkeypatch) -> None: - """Interactive add supports multiline body input terminated by marker.""" - from specfact_cli.adapters.registry import AdapterRegistry - - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=[], relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - import importlib - - add_module = importlib.import_module("backlog_core.commands.add") - - def _select(message: str, _choices: list[str], default: str | None = None) -> str: - lowered = message.lower() - if "issue type" in lowered: - return "story" - if "description format" in lowered: - return "markdown" - if "acceptance criteria" in lowered: - return "no" - if "add parent issue" in lowered: - return "no" - return default or "markdown" - - monkeypatch.setattr(add_module, "_select_with_fallback", _select) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--body-end-marker", - "::END::", - ], - input="Interactive title\nline one\nline two\n::END::\n\n\n\n", - ) - - assert result.exit_code == 0 - assert created_payloads - assert created_payloads[0]["description"] == "line one\nline two" - - -def test_backlog_add_interactive_ado_selects_current_iteration(monkeypatch) -> None: - """Interactive add can set sprint from current ADO iteration selection.""" - import importlib - - add_module = importlib.import_module("backlog_core.commands.add") - - from specfact_cli.adapters.registry import AdapterRegistry - - created_payloads: list[dict] = [] - adapter = _FakeAdoAdapter(items=[], relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - def _select(message: str, _choices: list[str], default: str | None = None) -> str: - lowered = message.lower() - if "issue type" in lowered: - return "story" - if "sprint/iteration" in lowered: - return "current: Project\\Sprint 42" - if "description format" in lowered: - return "markdown" - if "acceptance criteria" in lowered: - return "no" - if "add parent issue" in lowered: - return "no" - return "markdown" - - monkeypatch.setattr(add_module, "_select_with_fallback", _select) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "dominikusnold/Specfact CLI", - "--adapter", - "ado", - ], - input="ADO story\nbody line\n::END::\n\n\n", - ) - - assert result.exit_code == 0 - assert created_payloads - assert created_payloads[0]["sprint"] == "Project\\Sprint 42" - - -def test_backlog_add_interactive_collects_story_fields_and_parent(monkeypatch) -> None: - """Interactive story flow captures AC/priority/story points and selected parent.""" - import importlib - - add_module = importlib.import_module("backlog_core.commands.add") - - from specfact_cli.adapters.registry import AdapterRegistry - - items = [ - { - "id": "42", - "key": "FEAT-123", - "title": "Parent feature", - "type": "feature", - "status": "todo", - } - ] - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=items, relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - def _select(message: str, _choices: list[str], default: str | None = None) -> str: - lowered = message.lower() - if "issue type" in lowered: - return "story" - if "description format" in lowered: - return "markdown" - if "acceptance criteria" in lowered: - return "yes" - if "add parent issue" in lowered: - return "yes" - if "select parent issue" in lowered: - return "FEAT-123 | Parent feature | type=feature" - return "markdown" - - monkeypatch.setattr(add_module, "_select_with_fallback", _select) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - ], - input="Story title\nbody line\n::END::\n\nac line\n::END::\nhigh\n5\n", - ) - - assert result.exit_code == 0 - assert created_payloads - payload = created_payloads[0] - assert payload["acceptance_criteria"] == "ac line" - assert payload["priority"] == "high" - assert payload["story_points"] == 5 - assert payload["parent_id"] == "42" - - -def test_backlog_add_interactive_parent_selection_falls_back_to_all_candidates(monkeypatch) -> None: - """Interactive parent picker falls back to all candidates when type inference yields no matches.""" - import importlib - - add_module = importlib.import_module("backlog_core.commands.add") - - from specfact_cli.adapters.registry import AdapterRegistry - - items = [ - { - "id": "42", - "key": "STORY-1", - "title": "Parent", - "type": "custom", - "status": "todo", - } - ] - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=items, relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - def _select(message: str, _choices: list[str], default: str | None = None) -> str: - lowered = message.lower() - if "issue type" in lowered: - return "task" - if "description format" in lowered: - return "markdown" - if "acceptance criteria" in lowered: - return "no" - if "add parent issue" in lowered: - return "yes" - if "select parent issue" in lowered: - return "STORY-1 | Parent | type=custom" - return default or "markdown" - - monkeypatch.setattr(add_module, "_select_with_fallback", _select) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - ], - input="Task title\nBody line\n::END::\n\n\n\n", - ) - - assert result.exit_code == 0 - assert "No hierarchy-compatible parent candidates found from inferred types." in result.stdout - assert created_payloads - assert created_payloads[0]["parent_id"] == "42" - - -def test_backlog_add_ado_default_template_enables_epic_parent_candidates(monkeypatch) -> None: - """ADO add without explicit template should still resolve epic parent candidate for feature.""" - import importlib - - add_module = importlib.import_module("backlog_core.commands.add") - - from specfact_cli.adapters.registry import AdapterRegistry - - items = [ - { - "id": "900", - "key": "EPIC-900", - "title": "Platform Epic", - "work_item_type": "Epic", - "status": "New", - } - ] - created_payloads: list[dict] = [] - adapter = _FakeAdoAdapter(items=items, relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - def _select(message: str, _choices: list[str], default: str | None = None) -> str: - lowered = message.lower() - if "issue type" in lowered: - return "feature" - if "sprint/iteration" in lowered: - return "(skip sprint/iteration)" - if "description format" in lowered: - return "markdown" - if "acceptance criteria" in lowered: - return "no" - if "add parent issue" in lowered: - return "yes" - if "select parent issue" in lowered: - return "EPIC-900 | Platform Epic | type=epic" - return default or "markdown" - - monkeypatch.setattr(add_module, "_select_with_fallback", _select) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "dominikusnold/Specfact CLI", - "--adapter", - "ado", - ], - input="Feature title\nFeature body\n::END::\n\n\n", - ) - - assert result.exit_code == 0 - assert created_payloads - assert created_payloads[0].get("parent_id") == "900" - - -def test_backlog_add_warns_on_ambiguous_create_failure(monkeypatch) -> None: - """CLI warns user when duplicate-safe create fails with ambiguous transport error.""" - import requests - - from specfact_cli.adapters.registry import AdapterRegistry - - class _TimeoutAdapter(_FakeAdapter): - def create_issue(self, project_id: str, payload: dict) -> dict: # type: ignore[override] - _ = project_id, payload - raise requests.Timeout("network timeout") - - adapter = _TimeoutAdapter(items=[], relationships=[], created=[]) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--type", - "story", - "--title", - "Implement X", - "--non-interactive", - ], - ) - - assert result.exit_code == 1 - assert "may already exist remotely" in result.stdout - assert "before retrying to avoid duplicates" in result.stdout - - -def test_backlog_add_interactive_ado_sprint_lookup_uses_project_context(monkeypatch) -> None: - """ADO sprint lookup uses project_id-resolved org/project context before selection.""" - import importlib - - add_module = importlib.import_module("backlog_core.commands.add") - - from specfact_cli.adapters.registry import AdapterRegistry - - class _ContextAdoAdapter(_FakeAdapter): - def __init__(self) -> None: - super().__init__(items=[], relationships=[], created=[]) - self.org = None - self.project = None - - def _resolve_graph_project_context(self, project_id: str) -> tuple[str, str]: - assert project_id == "dominikusnold/Specfact CLI" - return "dominikusnold", "Specfact CLI" - - def _get_current_iteration(self) -> str | None: - if self.org == "dominikusnold" and self.project == "Specfact CLI": - return r"Specfact CLI\2026\Sprint 01" - return None - - def _list_available_iterations(self) -> list[str]: - if self.org == "dominikusnold" and self.project == "Specfact CLI": - return [r"Specfact CLI\2026\Sprint 01", r"Specfact CLI\2026\Sprint 02"] - return [] - - adapter = _ContextAdoAdapter() - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - def _select(message: str, _choices: list[str], default: str | None = None) -> str: - lowered = message.lower() - if "issue type" in lowered: - return "story" - if "sprint/iteration" in lowered: - return r"current: Specfact CLI\2026\Sprint 01" - if "description format" in lowered: - return "markdown" - if "acceptance criteria" in lowered: - return "no" - if "add parent issue" in lowered: - return "no" - return default or "markdown" - - monkeypatch.setattr(add_module, "_select_with_fallback", _select) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "dominikusnold/Specfact CLI", - "--adapter", - "ado", - ], - input="Story title\nBody\n::END::\n\n\n", - ) - - assert result.exit_code == 0 - assert adapter.created - assert adapter.created[0].get("sprint") == r"Specfact CLI\2026\Sprint 01" - - -def test_backlog_add_forwards_github_project_v2_provider_fields(monkeypatch, tmp_path: Path) -> None: - """backlog add forwards GitHub ProjectV2 config from custom config into create payload.""" - from specfact_cli.adapters.registry import AdapterRegistry - - config_file = tmp_path / "custom.yaml" - config_file.write_text( - """ -provider_fields: - github_project_v2: - project_id: PVT_PROJECT_1 - type_field_id: PVT_FIELD_TYPE - type_option_ids: - story: PVT_OPTION_STORY -""".strip(), - encoding="utf-8", - ) - - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=[], relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--template", - "github_projects", - "--custom-config", - str(config_file), - "--type", - "story", - "--title", - "Implement X", - "--body", - "Acceptance criteria", - "--non-interactive", - ], - ) - - assert result.exit_code == 0 - assert created_payloads - provider_fields = created_payloads[0].get("provider_fields") - assert isinstance(provider_fields, dict) - github_project_v2 = provider_fields.get("github_project_v2") - assert isinstance(github_project_v2, dict) - assert github_project_v2.get("project_id") == "PVT_PROJECT_1" - assert github_project_v2.get("type_field_id") == "PVT_FIELD_TYPE" - assert github_project_v2.get("type_option_ids", {}).get("story") == "PVT_OPTION_STORY" - - -def test_backlog_add_forwards_github_project_v2_from_backlog_config(monkeypatch, tmp_path: Path) -> None: - """backlog add loads GitHub ProjectV2 config from .specfact/backlog-config.yaml provider settings.""" - from specfact_cli.adapters.registry import AdapterRegistry - - spec_dir = tmp_path / ".specfact" - spec_dir.mkdir(parents=True, exist_ok=True) - (spec_dir / "backlog-config.yaml").write_text( - """ -backlog_config: - providers: - github: - adapter: github - project_id: nold-ai/specfact-demo-repo - settings: - provider_fields: - github_project_v2: - project_id: PVT_PROJECT_SPEC - type_field_id: PVT_FIELD_TYPE_SPEC - type_option_ids: - task: PVT_OPTION_TASK_SPEC - github_issue_types: - type_ids: - task: IT_TASK_SPEC -""".strip(), - encoding="utf-8", - ) - - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=[], relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-demo-repo", - "--adapter", - "github", - "--template", - "github_projects", - "--type", - "task", - "--title", - "Implement task", - "--body", - "Body", - "--non-interactive", - "--repo-path", - str(tmp_path), - ], - ) - - assert result.exit_code == 0 - assert created_payloads - provider_fields = created_payloads[0].get("provider_fields") - assert isinstance(provider_fields, dict) - github_project_v2 = provider_fields.get("github_project_v2") - assert isinstance(github_project_v2, dict) - assert github_project_v2.get("project_id") == "PVT_PROJECT_SPEC" - assert github_project_v2.get("type_field_id") == "PVT_FIELD_TYPE_SPEC" - assert github_project_v2.get("type_option_ids", {}).get("task") == "PVT_OPTION_TASK_SPEC" - github_issue_types = provider_fields.get("github_issue_types") - assert isinstance(github_issue_types, dict) - assert github_issue_types.get("type_ids", {}).get("task") == "IT_TASK_SPEC" - assert "repository issue-type mapping is not configured" not in result.stdout - - -def test_backlog_add_warns_when_github_issue_type_mapping_missing(monkeypatch) -> None: - """backlog add warns when repository issue-type mapping is unavailable for selected type.""" - from specfact_cli.adapters.registry import AdapterRegistry - - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=[], relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-demo-repo", - "--adapter", - "github", - "--type", - "spike", - "--title", - "Sample task", - "--body", - "Body", - "--non-interactive", - ], - ) - - assert result.exit_code == 0 - assert "repository issue-type mapping is not configured" in result.stdout - - -def test_backlog_add_prefers_root_issue_type_ids_when_provider_fields_issue_types_empty( - monkeypatch, tmp_path: Path -) -> None: - """backlog add should use root github_issue_types when provider_fields copy is empty.""" - from specfact_cli.adapters.registry import AdapterRegistry - - spec_dir = tmp_path / ".specfact" - spec_dir.mkdir(parents=True, exist_ok=True) - (spec_dir / "backlog-config.yaml").write_text( - """ -backlog_config: - providers: - github: - adapter: github - project_id: nold-ai/specfact-demo-repo - settings: - provider_fields: - github_issue_types: - type_ids: {} - github_issue_types: - type_ids: - task: IT_TASK_SPEC -""".strip(), - encoding="utf-8", - ) - - created_payloads: list[dict] = [] - adapter = _FakeAdapter(items=[], relationships=[], created=created_payloads) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda _adapter: adapter) - - result = runner.invoke( - backlog_app, - [ - "add", - "--project-id", - "nold-ai/specfact-demo-repo", - "--adapter", - "github", - "--type", - "task", - "--title", - "Implement task", - "--body", - "Body", - "--non-interactive", - "--repo-path", - str(tmp_path), - ], - ) - - assert result.exit_code == 0 - assert created_payloads - provider_fields = created_payloads[0].get("provider_fields") - assert isinstance(provider_fields, dict) - github_issue_types = provider_fields.get("github_issue_types") - assert isinstance(github_issue_types, dict) - assert github_issue_types.get("type_ids", {}).get("task") == "IT_TASK_SPEC" - assert "repository issue-type mapping is not configured" not in result.stdout diff --git a/modules/backlog-core/tests/unit/test_backlog_protocol.py b/modules/backlog-core/tests/unit/test_backlog_protocol.py deleted file mode 100644 index bd5db3c7..00000000 --- a/modules/backlog-core/tests/unit/test_backlog_protocol.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Unit tests for backlog graph protocol registration and conformance.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import pytest - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[4] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) -sys.path.insert(0, str(REPO_ROOT / "src")) - -from backlog_core.adapters.backlog_protocol import BacklogGraphProtocol -from backlog_core.analyzers.dependency import DependencyAnalyzer - -from specfact_cli.adapters.ado import AdoAdapter -from specfact_cli.adapters.github import GitHubAdapter -from specfact_cli.registry.bridge_registry import BRIDGE_PROTOCOL_REGISTRY - - -class _ValidAdapter: - def fetch_all_issues(self, project_id: str, filters: dict | None = None) -> list[dict]: - _ = project_id, filters - return [] - - def fetch_relationships(self, project_id: str) -> list[dict]: - _ = project_id - return [] - - def create_issue(self, project_id: str, payload: dict) -> dict: - _ = project_id, payload - return {"id": "1", "key": "1", "url": "https://example.test/1"} - - -class _InvalidAdapter: - def fetch_all_issues(self, project_id: str, filters: dict | None = None) -> list[dict]: - _ = project_id, filters - return [] - - -def test_backlog_graph_protocol_is_registered() -> None: - protocol = BRIDGE_PROTOCOL_REGISTRY.get_protocol("backlog_graph") - - assert protocol is BacklogGraphProtocol - - -def test_bridge_registry_resolves_adapter_implementations() -> None: - github_impl = BRIDGE_PROTOCOL_REGISTRY.get_implementation("backlog_graph", "github") - ado_impl = BRIDGE_PROTOCOL_REGISTRY.get_implementation("backlog_graph", "ado") - - assert github_impl is GitHubAdapter - assert ado_impl is AdoAdapter - - -def test_runtime_protocol_conformance_for_adapters() -> None: - assert isinstance(GitHubAdapter(), BacklogGraphProtocol) - assert isinstance(AdoAdapter(), BacklogGraphProtocol) - - -def test_dependency_analyzer_fail_fast_for_missing_protocol() -> None: - with pytest.raises(TypeError): - DependencyAnalyzer.validate_adapter_protocol(_InvalidAdapter()) - - -def test_dependency_analyzer_accepts_protocol_adapter() -> None: - DependencyAnalyzer.validate_adapter_protocol(_ValidAdapter()) diff --git a/modules/backlog-core/tests/unit/test_command_order.py b/modules/backlog-core/tests/unit/test_command_order.py deleted file mode 100644 index 464e0c71..00000000 --- a/modules/backlog-core/tests/unit/test_command_order.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Unit tests for backlog-core command ordering in help output.""" - -from __future__ import annotations - -import re -import sys -from pathlib import Path - -from typer.testing import CliRunner - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[4] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) -sys.path.insert(0, str(REPO_ROOT / "src")) - -from backlog_core.main import backlog_app - - -runner = CliRunner() - - -def _find_line(lines: list[str], needle: str) -> int: - pattern = re.compile(rf"^\s*│\s+{re.escape(needle)}(?:\s{{2,}}|\\s*$)") - for idx, line in enumerate(lines): - if pattern.search(line): - return idx - return -1 - - -def test_backlog_core_help_lists_command_groups_before_leaf_commands() -> None: - """`backlog --help` lists grouped commands before leaf commands for discoverability.""" - result = runner.invoke(backlog_app, ["--help"]) - assert result.exit_code == 0 - - lines = result.stdout.splitlines() - delta_idx = _find_line(lines, "delta") - sync_idx = _find_line(lines, "sync") - verify_idx = _find_line(lines, "verify-readiness") - - assert delta_idx != -1 - assert sync_idx != -1 - assert verify_idx != -1 - assert delta_idx < sync_idx - assert delta_idx < verify_idx diff --git a/modules/backlog-core/tests/unit/test_provider_enrichment.py b/modules/backlog-core/tests/unit/test_provider_enrichment.py deleted file mode 100644 index 421eef51..00000000 --- a/modules/backlog-core/tests/unit/test_provider_enrichment.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Unit tests for provider-specific enrichment used by backlog dependency analysis.""" - -from __future__ import annotations - -import sys -from pathlib import Path - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[4] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) -sys.path.insert(0, str(REPO_ROOT / "src")) - -from backlog_core.analyzers.dependency import DependencyAnalyzer -from backlog_core.graph.builder import BacklogGraphBuilder - -from specfact_cli.adapters.ado import AdoAdapter -from specfact_cli.adapters.github import GitHubAdapter -from specfact_cli.models.backlog_item import BacklogItem - - -def test_github_fetch_all_issues_enriches_normalized_item_type(monkeypatch) -> None: - """GitHub fetch_all_issues should include normalized type values for builder mapping.""" - adapter = GitHubAdapter(repo_owner="nold-ai", repo_name="specfact-cli", use_gh_cli=False) - - items = [ - BacklogItem( - id="101", - provider="github", - url="https://github.com/nold-ai/specfact-cli/issues/101", - title="Add dependency model", - body_markdown="Body", - state="open", - tags=["Type: Feature"], - ), - BacklogItem( - id="102", - provider="github", - url="https://github.com/nold-ai/specfact-cli/issues/102", - title="[Bug] Fix parser", - body_markdown="Body", - state="open", - tags=[], - ), - ] - monkeypatch.setattr(adapter, "fetch_backlog_items", lambda _filters: items) - - enriched = adapter.fetch_all_issues("nold-ai/specfact-cli") - - assert enriched[0]["type"] == "feature" - assert enriched[1]["type"] == "bug" - - -def test_github_fetch_relationships_extracts_blocks_and_related(monkeypatch) -> None: - """GitHub fetch_relationships should normalize body references into dependency edges.""" - adapter = GitHubAdapter(repo_owner="nold-ai", repo_name="specfact-cli", use_gh_cli=False) - monkeypatch.setattr( - adapter, - "fetch_all_issues", - lambda _project_id, filters=None: [ - { - "id": "10", - "body_markdown": "Blocks #11\nBlocked by #12\nRelated to #13", - "provider_fields": {}, - } - ], - ) - - relationships = adapter.fetch_relationships("nold-ai/specfact-cli") - edges = {(edge["source_id"], edge["target_id"], edge["type"]) for edge in relationships} - - assert ("10", "11", "blocks") in edges - assert ("12", "10", "blocks") in edges - assert ("10", "13", "relates") in edges - - -def test_ado_fetch_relationships_maps_forward_and_reverse_links(monkeypatch) -> None: - """ADO fetch_relationships should preserve hierarchy and blocker semantics.""" - adapter = AdoAdapter(org="nold-ai", project="specfact-cli", api_token="test-token") - monkeypatch.setattr( - adapter, - "fetch_all_issues", - lambda _project_id, filters=None: [ - { - "id": "100", - "provider_fields": { - "relations": [ - { - "rel": "System.LinkTypes.Hierarchy-Forward", - "url": "https://dev.azure.com/nold-ai/_apis/wit/workItems/101", - }, - { - "rel": "System.LinkTypes.Dependency-Reverse", - "url": "https://dev.azure.com/nold-ai/_apis/wit/workItems/200", - }, - { - "rel": "System.LinkTypes.Related", - "url": "https://dev.azure.com/nold-ai/_apis/wit/workItems/300", - }, - ] - }, - } - ], - ) - - relationships = adapter.fetch_relationships("nold-ai/specfact-cli") - edges = {(edge["source_id"], edge["target_id"], edge["type"]) for edge in relationships} - - assert ("100", "101", "parent") in edges - assert ("200", "100", "blocks") in edges - assert ("100", "300", "relates") in edges - - -def test_provider_enrichment_improves_typed_and_dependency_coverage(monkeypatch) -> None: - """Enriched provider outputs should yield non-zero typed/dependency coverage.""" - github = GitHubAdapter(repo_owner="nold-ai", repo_name="specfact-cli", use_gh_cli=False) - monkeypatch.setattr( - github, - "fetch_backlog_items", - lambda _filters: [ - BacklogItem( - id="1", - provider="github", - url="https://github.com/nold-ai/specfact-cli/issues/1", - title="Core epic", - body_markdown="Body", - state="open", - tags=["epic"], - ), - BacklogItem( - id="2", - provider="github", - url="https://github.com/nold-ai/specfact-cli/issues/2", - title="Implement feature", - body_markdown="Blocked by #1", - state="open", - tags=["feature"], - ), - ], - ) - - items = github.fetch_all_issues("nold-ai/specfact-cli") - relationships = github.fetch_relationships("nold-ai/specfact-cli") - - graph = ( - BacklogGraphBuilder(provider="github", template_name="github_projects") - .add_items(items) - .add_dependencies(relationships) - .build() - ) - coverage = DependencyAnalyzer(graph).coverage_analysis() - - assert coverage["properly_typed"] >= 2 - assert coverage["with_dependencies"] >= 1 diff --git a/modules/backlog-core/tests/unit/test_schema_extensions.py b/modules/backlog-core/tests/unit/test_schema_extensions.py deleted file mode 100644 index c401a207..00000000 --- a/modules/backlog-core/tests/unit/test_schema_extensions.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Unit tests for backlog-core schema extension access patterns.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -import yaml - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[4] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) -sys.path.insert(0, str(REPO_ROOT / "src")) - -from backlog_core.graph.config_schema import load_backlog_config_from_spec -from backlog_core.graph.models import BacklogGraph - -from specfact_cli.models.plan import Product -from specfact_cli.models.project import BundleManifest, ProjectBundle, ProjectMetadata - - -def test_module_package_declares_backlog_core_schema_extensions() -> None: - """module-package.yaml declares project metadata and bundle schema extensions.""" - module_package = REPO_ROOT / "modules" / "backlog-core" / "module-package.yaml" - data = yaml.safe_load(module_package.read_text(encoding="utf-8")) - schema_extensions = data.get("schema_extensions", {}) - - assert "project_bundle" in schema_extensions - assert "project_metadata" in schema_extensions - assert "backlog_core.backlog_graph" in schema_extensions["project_bundle"] - assert "backlog_core.backlog_config" in schema_extensions["project_metadata"] - - -def test_project_metadata_extension_roundtrip_via_bundle_save_load(tmp_path: Path) -> None: - """ProjectMetadata extension values persist through bundle serialization.""" - bundle_dir = tmp_path / ".specfact" / "projects" / "schema-extension-bundle" - metadata = ProjectMetadata(stability="alpha") - metadata.set_extension( - "backlog_core", - "backlog_config", - {"adapter": "github", "project_id": "nold-ai/specfact-cli", "template": "github_projects"}, - ) - bundle = ProjectBundle( - manifest=BundleManifest(schema_metadata=None, project_metadata=metadata), - bundle_name="schema-extension-bundle", - product=Product(themes=["Schema"]), - ) - - bundle.save_to_directory(bundle_dir) - loaded = ProjectBundle.load_from_directory(bundle_dir) - loaded_metadata = loaded.manifest.project_metadata - - assert loaded_metadata is not None - cfg = loaded_metadata.get_extension("backlog_core", "backlog_config") - assert isinstance(cfg, dict) - assert cfg["adapter"] == "github" - assert cfg["project_id"] == "nold-ai/specfact-cli" - - -def test_backlog_graph_json_serialization_roundtrip() -> None: - """BacklogGraph supports stable to_json/from_json serialization.""" - graph = BacklogGraph(provider="github", project_key="nold-ai/specfact-cli") - payload = graph.to_json() - restored = BacklogGraph.from_json(payload) - - assert restored.provider == "github" - assert restored.project_key == "nold-ai/specfact-cli" - - -def test_load_backlog_config_from_spec_supports_backlog_config(tmp_path: Path) -> None: - """Spec config loader reads backlog_config section from .specfact/spec.yaml.""" - spec_path = tmp_path / ".specfact" / "spec.yaml" - spec_path.parent.mkdir(parents=True, exist_ok=True) - spec_path.write_text( - """ -backlog_config: - dependencies: - template: github_projects - providers: - github: - adapter: github - project_id: nold-ai/specfact-cli -""".strip(), - encoding="utf-8", - ) - - cfg = load_backlog_config_from_spec(spec_path) - - assert cfg is not None - assert cfg.dependencies.template == "github_projects" - assert cfg.providers["github"].project_id == "nold-ai/specfact-cli" - - -def test_load_backlog_config_from_spec_supports_devops_stages(tmp_path: Path) -> None: - """Spec config loader accepts devops_stages section for workflow defaults.""" - spec_path = tmp_path / ".specfact" / "spec.yaml" - spec_path.parent.mkdir(parents=True, exist_ok=True) - spec_path.write_text( - """ -backlog_config: - dependencies: - template: github_projects -devops_stages: - plan: - default_action: generate-roadmap - monitor: - default_action: health-check -""".strip(), - encoding="utf-8", - ) - - cfg = load_backlog_config_from_spec(spec_path) - - assert cfg is not None - assert cfg.devops_stages["plan"].default_action == "generate-roadmap" - assert cfg.devops_stages["monitor"].default_action == "health-check" diff --git a/openspec/changes/backlog-module-ownership-cleanup/OWNERSHIP_MATRIX.md b/openspec/changes/backlog-module-ownership-cleanup/OWNERSHIP_MATRIX.md new file mode 100644 index 00000000..cbb65c8c --- /dev/null +++ b/openspec/changes/backlog-module-ownership-cleanup/OWNERSHIP_MATRIX.md @@ -0,0 +1,114 @@ +# Backlog Ownership Matrix + +## Command Ownership + +### Core CLI ownership that must be removed or retired + +- `src/specfact_cli/groups/backlog_group.py` + - Owns the top-level `backlog` category group and the `policy` member registration path. +- `src/specfact_cli/commands/backlog_commands.py` + - Backward-compatible shim that loads `specfact_backlog.backlog.commands` directly from core. +- `modules/backlog-core/module-package.yaml` + - Registers the built-in `backlog-core` package into the `specfact-backlog` bundle namespace. +- `modules/backlog-core/src/backlog_core/main.py` + - Directly owns `backlog add`, `backlog analyze-deps`, `backlog trace-impact`, `backlog sync`, `backlog diff`, `backlog promote`, `backlog verify-readiness`, `backlog generate-release-notes`, and `backlog delta *`. + +### Module ownership that already exists + +- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/module-package.yaml` + - Registers `nold-ai/specfact-backlog` as the official backlog bundle. +- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/commands.py` + - Owns `backlog daily`, `backlog refine`, `backlog init-config`, `backlog map-fields`, ceremony aliases, and auth flows. +- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/policy_engine/commands.py` + - Owns policy-engine command behavior for the backlog bundle. + +## Prompt And Template Ownership + +### Core prompt/template assets that must move or stop exporting as backlog-owned resources + +- `resources/prompts/specfact.backlog-add.md` +- `resources/prompts/specfact.backlog-daily.md` +- `resources/prompts/specfact.backlog-refine.md` +- `resources/prompts/specfact.sync-backlog.md` +- `resources/templates/backlog/defaults/*` +- `resources/templates/backlog/field_mappings/*` +- `resources/templates/backlog/frameworks/*` +- `resources/templates/backlog/personas/*` +- `resources/templates/backlog/providers/*` +- `src/specfact_cli/templates/defaults/user_story_v1.yaml` +- `src/specfact_cli/templates/frameworks/scrum/user_story_v1.yaml` +- `src/specfact_cli/templates/personas/product-owner/user_story_v1.yaml` +- `src/specfact_cli/utils/ide_setup.py` + - Hard-codes backlog prompt ids in `SPECFACT_COMMANDS`, so `init ide` currently exports backlog prompts from core resources. + +### Module-side prompt/template ownership that already exists + +- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/templates/registry.py` +- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/template_detector.py` +- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/mappers/template_config.py` + +## Runtime Helpers + +### Shared backlog contracts/infrastructure retained in core after cleanup + +- `src/specfact_cli/backlog/adapters/base.py` + - Minimal backlog adapter contract used by provider integrations and bundles. +- `src/specfact_cli/backlog/converter.py` + - Provider-normalization helpers used by core GitHub/ADO adapters. +- `src/specfact_cli/backlog/filters.py` + - Shared filter model used by provider adapters and bundle command adapters. +- `src/specfact_cli/backlog/mappers/*` + - Shared provider field mappers used by core GitHub/ADO adapters. +- `src/specfact_cli/adapters/*` + - Provider adapter infrastructure remains core framework code. +- `src/specfact_cli/models/backlog_item.py` + - Canonical data model used across providers and bundles. + +### Backlog-only helpers removed from core + +- `src/specfact_cli/backlog/ai_refiner.py` +- `src/specfact_cli/backlog/template_detector.py` +- `src/specfact_cli/backlog/format_detector.py` +- `src/specfact_cli/backlog/formats/*` +- `src/specfact_cli/backlog/adapters/local_yaml_adapter.py` + +## Duplicate Registration Tolerance + +- `src/specfact_cli/registry/module_packages.py` + - `_is_expected_duplicate_extension(...)` currently suppresses duplicate backlog overlaps for `nold-ai/specfact-backlog`. + - This is tolerated only because command ownership is split today and must be removed after migration. + +## Test Ownership + +### Core-side tests removed or reduced because they belonged to module-owned backlog behavior + +- `modules/backlog-core/tests/unit/*` +- `tests/unit/commands/test_backlog_commands.py` +- `tests/unit/commands/test_backlog_ceremony_group.py` +- backlog refinement/filtering E2E and integration suites under `tests/e2e/backlog/` and `tests/integration/backlog/` +- backlog helper unit suites under `tests/unit/backlog/` for ai-refinement/template/format/local-yaml behavior + +### Core-side tests retained + +- `tests/unit/test_backlog_module_ownership_cleanup.py` +- `tests/integration/test_command_package_runtime_validation.py` +- `tests/unit/utils/test_ide_setup.py` + +### Module tests that should own backlog feature behavior after migration + +- `/home/dom/git/nold-ai/specfact-cli-modules/tests/unit/specfact_backlog/test_map_fields_command.py` +- `/home/dom/git/nold-ai/specfact-cli-modules/tests/unit/specfact_backlog/test_auth_commands.py` +- `/home/dom/git/nold-ai/specfact-cli-modules/tests/unit/specfact_backlog/test_refine_adapter_contract.py` +- `/home/dom/git/nold-ai/specfact-cli-modules/tests/integration/specfact_backlog/test_command_apps.py` +- `/home/dom/git/nold-ai/specfact-cli-modules/tests/e2e/specfact_backlog/test_help_smoke.py` + +## Docs Impact + +- `docs/getting-started/tutorial-backlog-quickstart-demo.md` +- `docs/getting-started/tutorial-backlog-refine-ai-ide.md` +- `docs/guides/backlog-delta-commands.md` +- `docs/guides/backlog-dependency-analysis.md` +- `docs/guides/backlog-refinement.md` +- `docs/guides/policy-engine-commands.md` + +These docs currently describe backlog behavior without a strict core-vs-module ownership boundary and will need follow-up alignment once command ownership is cut over. diff --git a/openspec/changes/backlog-module-ownership-cleanup/TDD_EVIDENCE.md b/openspec/changes/backlog-module-ownership-cleanup/TDD_EVIDENCE.md new file mode 100644 index 00000000..4b1d5518 --- /dev/null +++ b/openspec/changes/backlog-module-ownership-cleanup/TDD_EVIDENCE.md @@ -0,0 +1,53 @@ +# TDD Evidence + +## Pre-Implementation Failing Run + +- Timestamp: 2026-03-06 21:46:54 CET +- Command: + +```bash +hatch run pytest tests/unit/test_backlog_module_ownership_cleanup.py -q +``` + +- Result: failed as expected + +### Failure Summary + +- `test_core_repo_no_longer_ships_backlog_owned_command_surfaces` + - Core still ships `modules/backlog-core`, `src/specfact_cli/commands/backlog_commands.py`, and `src/specfact_cli/groups/backlog_group.py`. +- `test_core_prompt_export_surface_excludes_backlog_prompts_and_templates` + - Core still ships backlog prompt files under `resources/prompts/` and backlog templates under `resources/templates/backlog/`. +- `test_backlog_duplicate_overlap_tolerance_is_not_required` + - `specfact_cli.registry.module_packages._is_expected_duplicate_extension(...)` still explicitly tolerates split backlog ownership. + +## Post-Implementation Passing Run + +- Timestamp: 2026-03-06 21:59:58 CET +- Command: + +```bash +hatch run pytest tests/unit/test_backlog_module_ownership_cleanup.py tests/unit/utils/test_ide_setup.py tests/integration/test_command_package_runtime_validation.py -q +``` + +- Result: passed + +### Passing Summary + +- Ownership-boundary tests now pass: + - core no longer ships `modules/backlog-core` + - core no longer ships backlog command shim/group files + - core no longer exports backlog prompt/template resources or prompt ids + - registry merge logic no longer tolerates split backlog overlap +- Regression slice passes for: + - core IDE prompt export behavior + - temp-home command-package runtime validation with marketplace bundles + +### OpenSpec Validation + +- Command: + +```bash +openspec validate backlog-module-ownership-cleanup --strict +``` + +- Result: passed diff --git a/openspec/changes/backlog-module-ownership-cleanup/specs/backlog-module-ownership/spec.md b/openspec/changes/backlog-module-ownership-cleanup/specs/backlog-module-ownership/spec.md index b23f2bed..8134c9c9 100644 --- a/openspec/changes/backlog-module-ownership-cleanup/specs/backlog-module-ownership/spec.md +++ b/openspec/changes/backlog-module-ownership-cleanup/specs/backlog-module-ownership/spec.md @@ -9,6 +9,11 @@ The system SHALL treat `nold-ai/specfact-backlog` as the sole owner of user-faci - **THEN** user-facing backlog feature commands are provided by the installed backlog module - **AND** core does not ship a parallel built-in backlog command surface for the same feature commands. +#### Scenario: Core keeps only shared backlog framework contracts +- **WHEN** backlog ownership is resolved after migration +- **THEN** core retains only shared provider integrations, generic data models, and minimal backlog contracts reused outside the backlog bundle +- **AND** backlog-only command implementations, prompt resources, templates, and refinement helpers are not owned by core. + ### Requirement: Backlog Prompt And Template Assets Must Be Module-Owned Backlog-specific prompts, prompt templates, and backlog template semantics SHALL be owned by the backlog module, not by `specfact-cli` core. diff --git a/openspec/changes/backlog-module-ownership-cleanup/tasks.md b/openspec/changes/backlog-module-ownership-cleanup/tasks.md index 3571528a..781ee607 100644 --- a/openspec/changes/backlog-module-ownership-cleanup/tasks.md +++ b/openspec/changes/backlog-module-ownership-cleanup/tasks.md @@ -1,26 +1,26 @@ ## 1. Ownership Inventory And Spec Setup -- [ ] 1.1 Freeze the backlog ownership matrix: commands, prompts, templates, helpers, tests, and docs currently split between core and `specfact-backlog`. -- [ ] 1.2 Add spec deltas covering module-only backlog command ownership and backlog-owned prompt/template resources. -- [ ] 1.3 Capture the expected keep-in-core list for shared contracts/models/provider infrastructure. +- [x] 1.1 Freeze the backlog ownership matrix: commands, prompts, templates, helpers, tests, and docs currently split between core and `specfact-backlog`. +- [x] 1.2 Add spec deltas covering module-only backlog command ownership and backlog-owned prompt/template resources. +- [x] 1.3 Capture the expected keep-in-core list for shared contracts/models/provider infrastructure. ## 2. Test-First Cleanup -- [ ] 2.1 Add failing tests proving core no longer directly owns backlog command surfaces that belong to `nold-ai/specfact-backlog`. -- [ ] 2.2 Add failing tests proving backlog prompts/templates are no longer exported from core resource paths after migration. -- [ ] 2.3 Add failing regression coverage proving duplicate backlog overlap handling is no longer required in normal registration. -- [ ] 2.4 Record the failing evidence in `TDD_EVIDENCE.md`. +- [x] 2.1 Add failing tests proving core no longer directly owns backlog command surfaces that belong to `nold-ai/specfact-backlog`. +- [x] 2.2 Add failing tests proving backlog prompts/templates are no longer exported from core resource paths after migration. +- [x] 2.3 Add failing regression coverage proving duplicate backlog overlap handling is no longer required in normal registration. +- [x] 2.4 Record the failing evidence in `TDD_EVIDENCE.md`. ## 3. Production Refactor -- [ ] 3.1 Remove or retire `modules/backlog-core` command ownership from `specfact-cli`. -- [ ] 3.2 Remove or retire core backlog command/group shims that still expose backlog feature commands directly. -- [ ] 3.3 Move backlog-specific prompts, templates, and backlog-only helpers into `specfact-backlog`. -- [ ] 3.4 Retain only approved shared contracts/models/provider infrastructure in core. -- [ ] 3.5 Remove duplicate-overlap toleration that exists only for split backlog ownership. +- [x] 3.1 Remove or retire `modules/backlog-core` command ownership from `specfact-cli`. +- [x] 3.2 Remove or retire core backlog command/group shims that still expose backlog feature commands directly. +- [x] 3.3 Move backlog-specific prompts, templates, and backlog-only helpers into `specfact-backlog`. +- [x] 3.4 Retain only approved shared contracts/models/provider infrastructure in core. +- [x] 3.5 Remove duplicate-overlap toleration that exists only for split backlog ownership. ## 4. Validation -- [ ] 4.1 Re-run the new ownership tests and record passing evidence in `TDD_EVIDENCE.md`. -- [ ] 4.2 Re-run the active CLI runtime validation for backlog command registration. -- [ ] 4.3 Run `openspec validate backlog-module-ownership-cleanup --strict`. +- [x] 4.1 Re-run the new ownership tests and record passing evidence in `TDD_EVIDENCE.md`. +- [x] 4.2 Re-run the active CLI runtime validation for backlog command registration. +- [x] 4.3 Run `openspec validate backlog-module-ownership-cleanup --strict`. diff --git a/pyproject.toml b/pyproject.toml index 74dd1008..aa001623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.40.1" +version = "0.40.2" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" @@ -388,7 +388,6 @@ sources = ["src"] "resources/schemas" = "specfact_cli/resources/schemas" "resources/mappings" = "specfact_cli/resources/mappings" "resources/keys" = "specfact_cli/resources/keys" -"modules/backlog-core" = "specfact_cli/modules/backlog-core" "modules/bundle-mapper" = "specfact_cli/modules/bundle-mapper" # Note: resources/semgrep files are in src/specfact_cli/resources/semgrep/ and are automatically included diff --git a/resources/prompts/specfact.backlog-add.md b/resources/prompts/specfact.backlog-add.md deleted file mode 100644 index 84427891..00000000 --- a/resources/prompts/specfact.backlog-add.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -description: "Create backlog items with guided interactive flow and hierarchy checks" ---- - -# SpecFact Backlog Add Command - -## User Input - -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). - -## Purpose - -Create a new backlog item in GitHub or Azure DevOps using the `specfact backlog add` workflow. The command supports interactive prompts, parent hierarchy validation, DoR checks, and provider-specific fields. - -**When to use:** Adding new work items (epic/feature/story/task/bug) with consistent quality and parent-child structure. - -**Quick:** `/specfact.backlog-add --adapter github --project-id owner/repo --type story --title "..."` - -## Parameters - -### Required - -- `--adapter ADAPTER` - Backlog adapter (`github`, `ado`) -- `--project-id PROJECT` - Project context - - GitHub: `owner/repo` - - ADO: `org/project` - -### Common options - -- `--type TYPE` - Backlog item type (provider/template specific) -- `--title TITLE` - Item title -- `--body BODY` - Item body/description -- `--parent PARENT_ID` - Optional parent issue/work item id -- `--non-interactive` - Disable prompt flow and require explicit inputs -- `--check-dor` - Run Definition of Ready checks before create -- `--template TEMPLATE` - Optional backlog template override -- `--custom-config PATH` - Optional mapping/config override file - -### Adapter-specific options - -- GitHub: - - `--repo-owner OWNER` - - `--repo-name NAME` - - `--github-token TOKEN` (or `GITHUB_TOKEN`) -- Azure DevOps: - - `--ado-org ORG` - - `--ado-project PROJECT` - - `--ado-token TOKEN` (or `AZURE_DEVOPS_TOKEN`) - - `--ado-base-url URL` (optional) - -## Workflow - -### Step 1: Execute command - -Run the CLI command with user arguments: - -```bash -specfact backlog add [OPTIONS] -``` - -### Step 2: Interactive completion (if inputs are missing) - -- Prompt for missing required fields. -- Prompt for optional quality fields (acceptance criteria, points, priority) when supported. -- Validate parent selection and allowed hierarchy before create. - -### Step 3: Confirm and create - -- Show planned create payload summary. -- Execute provider create operation. -- Return created item id/key/url. - -## CLI Enforcement - -- Always execute `specfact backlog add` for creation. -- Do not create provider issues/work items directly outside CLI unless user explicitly requests a manual path. - -## Input Contract - -- This command does not use `--export-to-tmp`/`--import-from-tmp` artifacts. -- Provide values through CLI options or interactive prompts; do not fabricate external tmp-file schemas. -- Do not ask Copilot to output `## Item N:` sections, `**ID**` labels, or markdown tmp files for this command. - -## Context - -{ARGS} diff --git a/resources/prompts/specfact.backlog-daily.md b/resources/prompts/specfact.backlog-daily.md deleted file mode 100644 index e4675434..00000000 --- a/resources/prompts/specfact.backlog-daily.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -description: "Daily standup and sprint review with story-by-story walkthrough" ---- - -# SpecFact Daily Standup Command - -## User Input - -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). - -## Purpose - -Run a daily standup view and optional interactive walkthrough of backlog items (GitHub Issues, Azure DevOps work items) with the DevOps team: list items in scope, review story-by-story, highlight current focus, surface issues or open questions, and allow adding discussion notes as annotation comments on the issue. - -**When to use:** Daily standup, sprint review, quick status sync with the team. - -**Quick:** `/specfact.daily` or `/specfact.backlog-daily` with optional adapter and filters. From a clone, org/repo or org/project are auto-detected from git remote. - -## Parameters - -### Required - -- `ADAPTER` - Backlog adapter name (github, ado, etc.) - -### Adapter Configuration (same as backlog-refine) - -**GitHub:** `--repo-owner`, `--repo-name`, `--github-token` (optional). -**Azure DevOps:** `--ado-org`, `--ado-project`, `--ado-team` (optional), `--ado-base-url`, `--ado-token` (optional). - -When run from a **clone**, org/repo or org/project are inferred from `git remote get-url origin`; no need to pass them unless overriding. - -### Filters - -- `--state STATE` - Filter by state (e.g. open, Active). Use `--state any` to disable state filtering. -- `--assignee USERNAME` or `--assignee me` - Filter by assignee. Use `--assignee any` to disable assignee filtering. -- `--search QUERY` - Provider-specific search query -- `--release RELEASE` - Filter by release identifier -- `--id ISSUE_ID` - Filter to one exact backlog item ID -- `--sprint SPRINT` / `--iteration PATH` - Filter by sprint/iteration (e.g. `current`) -- `--limit N` - Max items (default 20) -- `--first-issues N` / `--last-issues N` - Optional issue window (oldest/newest by numeric ID, mutually exclusive) -- `--blockers-first` - Sort items with blockers first -- `--show-unassigned` / `--unassigned-only` - Include or show only unassigned items - -### Daily-Specific Options - -- `--interactive` - Step-by-step review: select items with arrow keys, view full detail (refine-like) and **existing comments** on each issue -- `--copilot-export PATH` - Write summarized progress per story to a file for Copilot slash-command use -- `--summarize` - Output a prompt (instruction + filter context + standup data) to **stdout** for Copilot or slash command to generate a standup summary -- `--summarize-to PATH` - Write the same summarize prompt to a **file** -- `--comments` / `--annotations` - Include descriptions and comments in `--copilot-export` and summarize output -- `--first-comments N` / `--last-comments N` - Optional comment window for export/summarize outputs (`--comments`); default includes all comments -- `--suggest-next` - In interactive mode, show suggested next item by value score -- `--post` with `--yesterday`, `--today`, `--blockers` - Post a standup comment to the first item's issue (when adapter supports comments) -- Interactive navigation action `Post standup update` - Post yesterday/today/blockers to the currently selected story during `--interactive` walkthrough - -## Workflow - -### Step 1: Run Daily Standup - -Execute the CLI with adapter and optional filters: - -```bash -specfact backlog daily $ADAPTER [--state open] [--sprint current] [--assignee me] [--limit 20] -``` - -Or use the slash command with arguments: `/specfact.backlog-daily --adapter ado --sprint current` - -**What you see:** A standup table (assigned items) and a "Pending / open for commitment" table (unassigned items in scope). Each row shows ID, title, status, last updated, and optional yesterday/today/blockers from the item body. - -### Step 2: Interactive Story-by-Story Review (with DevOps team) - -When the user runs **`--interactive`** (or the slash command drives an interactive flow): - -1. **For each story** (one at a time): - - **Present** the item: ID, title, status, assignees, last updated, description, acceptance criteria, standup fields (yesterday/today/blockers), and the **latest existing comment** (when the adapter supports fetching comments). - - **Interactive comment scope**: If older comments exist, explicitly mention the count of hidden comments and guide users to export options for full context. - - **Highlight current focus**: What is the team member working on? What is the next intended step? - - **Surface issues or open questions**: Blockers, ambiguities, dependencies, or decisions needed. - - **Allow discussion notes**: If the team agrees, suggest or add a **comment** on the issue (e.g. "Standup YYYY-MM-DD: …" or "Discussion: …") so the discussion is captured as an annotation. Only add comments when the user explicitly approves (e.g. "add that as a comment"). - - If in CLI interactive navigation, use **Post standup update** to write the note to the selected story directly. - - **Move to next** only when the team is done with this story (e.g. "next", "done"). - -2. **Rules**: - - Do not update the backlog item body or title unless the user asks for a refinement (use `specfact backlog refine` for that). - - Comments are for **discussion notes** and standup updates; keep them short and actionable. - - If the adapter does not support comments, report that clearly and skip adding comments. - -3. **Navigation**: After each story, offer "Next story", "Previous story", "Back to list", "Exit" (or equivalent) so the team can move through the list without re-running the command. - -### Step 3: Generate Standup Summary (optional) - -When the user has run `specfact backlog daily ... --summarize` or `--summarize-to PATH`, the output is a **prompt** containing: - -- A short instruction: generate a concise daily standup summary from the following data. -- Filter context (adapter, state, sprint, assignee, limit). -- Per-item data (same as `--copilot-export`: ID, title, status, assignees, last updated, progress, blockers). - -**Use this output** by pasting it into Copilot or invoking the slash command `specfact.daily` with this context, so the AI can produce a short narrative summary (e.g. "Today's standup: 3 in progress, 1 blocked, 2 pending commitment …"). - -## Comments on Issues - -- **Interactive detail view** shows only the **latest comment** plus a hint when additional comments exist, to keep standup readable. -- **Full comment context**: use `--copilot-export --comments` or `--summarize --comments` (optional `--first-comments N` / `--last-comments N`) to include full or scoped comment history. -- **Adding comments**: When the team agrees to record a discussion note or standup update, add it as a comment on the issue (via `--post` for first-item standup lines or interactive **Post standup update** for selected stories). Do not invent comments; only suggest or add when the user approves. - -## CLI Enforcement - -- Execute `specfact backlog daily` (or equivalent) first; use its output as context. -- Use `--interactive` for story-by-story walkthrough; use `--summarize` or `--summarize-to` when a standup summary prompt is needed. -- Use `--copilot-export` when you need a file of item summaries for reference during standup. - -## Output Contract - -- This command does not support `--import-from-tmp`; do not invent a tmp import schema. -- Do not instruct Copilot to produce `## Item N:` blocks or `**ID**`/`**Body**` tmp artifacts for this command. -- If you write `--copilot-export` or `--summarize-to` artifacts, keep item sections and IDs unchanged from CLI output. - -## Context - -{ARGS} diff --git a/resources/prompts/specfact.backlog-refine.md b/resources/prompts/specfact.backlog-refine.md deleted file mode 100644 index 2d6f83f5..00000000 --- a/resources/prompts/specfact.backlog-refine.md +++ /dev/null @@ -1,557 +0,0 @@ ---- -description: "Refine backlog items using template-driven AI assistance" ---- - -# SpecFact Backlog Refinement Command - -## User Input - -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). - -## Purpose - -Refine backlog items from DevOps tools (GitHub Issues, Azure DevOps, etc.) into structured, template-compliant work items using AI-assisted refinement with template detection and validation. - -**When to use:** Standardizing backlog items, enforcing corporate templates (user stories, defects, spikes, enablers), preparing items for sprint planning. - -**Quick:** `/specfact.backlog-refine --adapter github --labels feature,enhancement` or `/specfact.backlog-refine --adapter ado --sprint "Sprint 1"` - -## Parameters - -### Required - -- `ADAPTER` - Backlog adapter name (github, ado, etc.) - -### Adapter Configuration (Required for GitHub/ADO) - -**GitHub Adapter:** - -- `--repo-owner OWNER` - GitHub repository owner (required for GitHub adapter) -- `--repo-name NAME` - GitHub repository name (required for GitHub adapter) -- `--github-token TOKEN` - GitHub API token (optional, uses GITHUB_TOKEN env var or gh CLI if not provided) - -**Azure DevOps Adapter:** - -- `--ado-org ORG` - Azure DevOps organization or collection name (required for ADO adapter, except when collection is in base_url) -- `--ado-project PROJECT` - Azure DevOps project (required for ADO adapter) -- `--ado-team TEAM` - Azure DevOps team name (optional, defaults to project name for iteration lookup) -- `--ado-base-url URL` - Azure DevOps base URL (optional, defaults to `https://dev.azure.com` for cloud) - - **Cloud**: `https://dev.azure.com` (default) - - **On-premise**: `https://server` or `https://server/tfs/collection` (if collection included) -- `--ado-token TOKEN` - Azure DevOps PAT (optional, uses AZURE_DEVOPS_TOKEN env var or stored token if not provided) - -**ADO Configuration Notes:** - -- **Cloud (Azure DevOps Services)**: Always requires `--ado-org` and `--ado-project`. Base URL defaults to `https://dev.azure.com`. -- **On-premise (Azure DevOps Server)**: - - If base URL includes collection (e.g., `https://server/tfs/DefaultCollection`), `--ado-org` is optional. - - If base URL doesn't include collection, provide collection name via `--ado-org`. -- **API Endpoints**: - - WIQL queries use POST to `{base_url}/{org}/{project}/_apis/wit/wiql?api-version=7.1` (project-level) - - Work items batch GET uses `{base_url}/{org}/_apis/wit/workitems?ids={ids}&api-version=7.1` (organization-level) - - The `api-version` parameter is **required** for all ADO API calls - -### Filters - -- `--labels LABELS` or `--tags TAGS` - Filter by labels/tags (comma-separated, e.g., "feature,enhancement") -- `--state STATE` - Filter by state (case-insensitive, e.g., "open", "closed", "Active", "New"). Use `--state any` to disable state filtering. -- `--assignee USERNAME` - Filter by assignee (case-insensitive): - - **GitHub**: Login or @username (e.g., "johndoe" or "@johndoe") - - **ADO**: displayName, uniqueName, or mail (e.g., "Jane Doe" or `"jane.doe@example.com"`) - - Use `--assignee any` to disable assignee filtering. -- `--iteration PATH` - Filter by iteration path (ADO format: "Project\\Sprint 1", case-insensitive) -- `--sprint SPRINT` - Filter by sprint (case-insensitive): - - **ADO**: Use full iteration path (e.g., "Project\\Sprint 1") to avoid ambiguity when multiple sprints share the same name - - If omitted, defaults to current active iteration for the team - - Ambiguous name-only matches will prompt for explicit iteration path -- `--release RELEASE` - Filter by release identifier (case-insensitive) -- `--limit N` - Maximum number of items to process in this refinement session (caps batch size) -- `--first-issues N` / `--last-issues N` - Process only the first or last N items after filters/refinement checks (mutually exclusive; sorted by numeric issue/work-item ID, lower=older, higher=newer) -- `--ignore-refined` / `--no-ignore-refined` - When set (default), exclude already-refined items so `--limit` applies to items that need refinement. Use `--no-ignore-refined` to process the first N items in order. -- `--id ISSUE_ID` - Refine only this backlog item (issue or work item ID). Other items are ignored. -- `--persona PERSONA` - Filter templates by persona (product-owner, architect, developer) -- `--framework FRAMEWORK` - Filter templates by framework (agile, scrum, safe, kanban) - -### Template Selection - -- `--template TEMPLATE_ID` or `-t TEMPLATE_ID` - Target template ID (default: auto-detect) -- `--auto-accept-high-confidence` - Auto-accept refinements with confidence >= 0.85 - -### Preview and Writeback - -- `--preview` / `--no-preview` - Preview mode: show what will be written without updating backlog (default: --preview) - - **Preview mode shows**: Full item details (title, body, metrics, acceptance_criteria, work_item_type, etc.) - - **Preview mode skips**: Interactive refinement prompts (use `--write` to enable interactive refinement) -- `--write` - Write mode: explicitly opt-in to update remote backlog (requires --write flag) - -### Export/Import for Copilot Processing - -- `--export-to-tmp` - Export backlog items to temporary file for copilot processing (default: `/tmp/specfact-backlog-refine-.md`) -- `--import-from-tmp` - Import refined content from temporary file after copilot processing (default: `/tmp/specfact-backlog-refine--refined.md`) -- `--tmp-file PATH` - Custom temporary file path (overrides default) -- `--first-comments N` / `--last-comments N` - Optional comment window for preview and write-mode prompt context (default preview shows last 2; write prompts include full comments by default) - -**Export/Import Workflow**: - -1. Export items: `specfact backlog refine --adapter github --export-to-tmp --repo-owner OWNER --repo-name NAME` -2. Process with copilot: Open exported file and follow the embedded `## Copilot Instructions` and per-item template guidance (`Target Template`, `Required Sections`, `Optional Sections`). Save as `-refined.md` -3. Import refined: `specfact backlog refine --adapter github --import-from-tmp --repo-owner OWNER --repo-name NAME --write` - -When refining from an exported file, treat the embedded instructions in that file as the source of truth for required structure and formatting. - -**Critical content-preservation rule**: -- Never summarize, shorten, or silently remove details from story content. -- Preserve all existing requirements, constraints, business value, and feature intent. -- Refinement must increase clarity/structure, not reduce scope/detail. - -**Exact tmp structure contract (`--import-from-tmp`)**: - -- Keep one section per item with this exact heading pattern: `## Item N: `. -- The first non-empty line of each item block MUST be the `## Item N: ...` heading. -- Keep and preserve these metadata labels exactly (order may vary; labels must match): - - `**ID**: <original exported id>` (**mandatory and unchanged**) - - `**URL**: <url>` - - `**State**: <state>` - - `**Provider**: <provider>` -- Keep body in this exact form (fence language must be `markdown`): - - `**Body**:` - - ```` ```markdown ... ``` ```` -- Optional parsed fields (if present) must use exact labels: - - `**Acceptance Criteria**:` - - `**Metrics**:` with lines containing `Story Points:`, `Business Value:`, `Priority:` -- Do not prepend explanatory text, summaries, or headers before the first `## Item N:` block. -- Do not rename labels (`**ID**`, `**Body**`, `**Acceptance Criteria**`, `**Metrics**`). - -Exact item example: - -````markdown -## Item 1: Improve backlog refine import mapping - -**ID**: 123 -**URL**: https://dev.azure.com/org/project/_workitems/edit/123 -**State**: Active -**Provider**: ado - -**Metrics**: -- Story Points: 5 -- Business Value: 8 -- Priority: 2 - -**Acceptance Criteria**: -- [ ] Mapping uses configured story points field - -**Body**: -```markdown -## Description - -Refined body content. -``` -```` - -If `**ID**` is missing or changed, import cannot map refined content to backlog items and writeback will fail. - -**Provider-specific body contract (critical)**: - -- **GitHub**: - - Keep template narrative sections in `**Body**` markdown (for example `## As a`, `## I want`, `## So that`, `## Acceptance Criteria` when required by template). - - Metrics may be in `**Metrics**` and/or body sections if your template expects body headings. -- **ADO**: - - Keep narrative/template sections in `**Body**` markdown. - - Keep structured metadata in `**Metrics**` (`Story Points`, `Business Value`, `Priority`). - - Do **not** add metadata-only headings (`## Story Points`, `## Business Value`, `## Priority`, `## Work Item Type`, `## Area Path`, `## Iteration Path`) inside body text. - - Do **not** duplicate `## Description` heading text into the narrative content. - -**Template-driven refinement method (mandatory)**: - -- Use exported `**Target Template**`, `**Required Sections**`, and `**Optional Sections**` as the authoritative contract for each item. -- Preserve all functional and non-functional requirements; never silently drop details. -- Improve clarity, specificity, and testability (SMART-style) without scope reduction. -- If one story is too large, propose split candidates in `## Notes`; do not remove detail from the original item silently. - -**What to include / exclude boundaries**: - -- Include: - - All original business intent, user value, constraints, assumptions, dependencies, and acceptance signals. - - Explicit acceptance criteria and measurable outcomes. -- Exclude: - - Generic summaries that replace detailed requirements. - - Placeholder text (`unspecified`, `TBD`, `no info`) when original detail exists. - - Extra wrapper prose outside `## Item N:` blocks. - -One-shot GitHub scaffold example: - -````markdown -## Item 1: Improve authentication flow - -**ID**: 42 -**URL**: https://github.com/org/repo/issues/42 -**State**: open -**Provider**: github - -**Metrics**: -- Story Points: 8 -- Business Value: 13 -- Priority: 2 - -**Acceptance Criteria**: -- [ ] Token refresh handles expiry and retry behavior - -**Body**: -```markdown -## As a -platform user - -## I want -reliable authentication and token refresh behavior - -## So that -I can access protected resources without disruption - -## Acceptance Criteria -- [ ] Valid refresh token rotates and issues new access token -- [ ] Expired/invalid token returns clear error and audit event -``` -```` - -One-shot ADO scaffold example: - -````markdown -## Item 2: Harden login reliability - -**ID**: 108 -**URL**: https://dev.azure.com/org/project/_workitems/edit/108 -**State**: Active -**Provider**: ado - -**Metrics**: -- Story Points: 5 -- Business Value: 8 -- Priority: 2 - -**Acceptance Criteria**: -- [ ] All required acceptance checks are explicit and testable - -**Body**: -```markdown -## As a -registered user - -## I want -the login flow to handle token expiry and retries safely - -## So that -I can complete authentication without ambiguity or data loss - -## Acceptance Criteria -- [ ] Expired access token triggers refresh workflow -- [ ] Failed refresh prompts re-authentication with clear guidance -``` -```` - -**Comment context in export**: - -- Export includes item comments when adapter supports comment retrieval (GitHub + ADO). -- Export always includes full comment history (no truncation). -- Use `--first-comments N` or `--last-comments N` only to adjust preview output density. -- For refined import readiness, the `-refined.md` artifact should omit the instruction header and keep only item sections. - -### Definition of Ready (DoR) - -- `--check-dor` - Check Definition of Ready (DoR) rules before refinement (loads from `.specfact/dor.yaml`) - -### OpenSpec Integration - -- `--bundle BUNDLE` or `-b BUNDLE` - OpenSpec bundle path to import refined items -- `--auto-bundle` - Auto-import refined items to OpenSpec bundle -- `--openspec-comment` - Add OpenSpec change proposal reference as comment (preserves original body) - -### Generic Search - -- `--search QUERY` or `-s QUERY` - Search query using provider-specific syntax (e.g., GitHub: "is:open label:feature") - -## Workflow - -### Step 1: Execute CLI Command - -Execute the SpecFact CLI command with user-provided arguments: - -```bash -specfact backlog refine $ADAPTER \ - [--labels LABELS] [--state STATE] [--assignee USERNAME] \ - [--iteration PATH] [--sprint SPRINT] [--release RELEASE] \ - [--limit N] \ - [--persona PERSONA] [--framework FRAMEWORK] \ - [--template TEMPLATE_ID] [--auto-accept-high-confidence] \ - [--preview] [--write] \ - [--bundle BUNDLE] [--auto-bundle] \ - [--search QUERY] -``` - -**Capture CLI output**: - -- List of backlog items found -- Template detection results for each item -- Refinement prompts for IDE AI copilot -- Validation results -- Preview of what will be written (if --preview) -- Writeback confirmation (if --write) - -### Step 2: Process Refinement Prompts (If Items Need Refinement) - -**When CLI generates refinement prompts**: - -1. **For each item needing refinement**: - - CLI displays a refinement prompt - - Copy the prompt and execute it in your IDE AI copilot - - Get refined content from AI copilot response - - Paste refined content back to CLI when prompted - -2. **CLI validation**: - - CLI validates refined content against template requirements - - CLI provides confidence score - - CLI shows preview of changes (original vs refined) - -3. **User confirmation**: - - Review preview (fields that will be updated vs preserved) - - Accept or reject refinement - - If accepted and --write flag set, CLI updates remote backlog - -4. **Session control**: - - Use `:skip` to skip the current item without updating - - Use `:quit` or `:abort` to cancel the entire session gracefully - - Session cancellation shows summary and exits without error - -### Interactive refinement (Copilot mode) - -When refining backlog items in Copilot mode (e.g. after export to tmp or during a refinement session), follow this **per-story loop** so the PO and stakeholders can review and approve before any update: - -1. **For each story** (one at a time): - - **Present** the refined story in a clear, readable format: - - Use headings for Title, Body, Acceptance Criteria, Metrics. - - Use tables or panels for structured data so it is easy to scan. - - **Assess specification level** so the DevOps team knows if the story is ready, under-specified, or over-specified: - - **Under-specified**: Missing acceptance criteria, vague scope, unclear "so that" or user value. List evidence (e.g. "No AC", "Scope could mean X or Y"). Suggest what to add. - - **Over-specified**: Too much implementation detail, too many sub-steps for one story, or solution prescribed instead of outcome. List evidence and suggest what to trim or split. - - **Fit for scope and intent**: Clear persona, capability, benefit, and testable AC; appropriate size. State briefly why it is ready (and, if DoR is in use, that DoR is satisfied). - - **List ambiguities** or open questions (e.g. unclear scope, missing acceptance criteria, conflicting assumptions). - - **Ask** the PO and other stakeholders for clarification: "Please review the refined story above. Do you want any changes? Any ambiguities to resolve? Should this story be split?" - - **If the user provides feedback**: Re-refine the story incorporating the feedback, then repeat from "Present" for this story. - - **Only when the user explicitly approves** (e.g. "looks good", "approved", "no changes"): Mark this story as done and move to the **next** story. - - **Do not update** the backlog item (or write to the refined file as final) until the user has approved this story. - -2. **Formatting**: - - Use clear headings, bullet lists, and optional tables/panels so refinement sessions are easy to follow and enjoyable. - - Keep each story’s block self-contained so stakeholders can focus on one item at a time. - -3. **Rule**: The backlog item (or exported block) must only be updated/finalized **after** the user has approved the refined content for that story. Then proceed to the next story with the same process. - -### Step 3: Present Results - -Display refinement results: - -- Number of items refined -- Number of items skipped -- Template matches found -- Confidence scores -- Preview status (if --preview) -- Writeback status (if --write) - -## CLI Enforcement - -**CRITICAL**: Always use SpecFact CLI commands. See [CLI Enforcement Rules](./shared/cli-enforcement.md) for details. - -**Rules**: - -- Execute CLI first - never modify backlog items directly -- Use refinement prompts generated by CLI -- Validate refined content through CLI -- Use --preview flag by default for safety -- Use --write flag only when ready to update backlog - -## Field Preservation Policy - -**Fields that will be UPDATED**: - -- `title`: Updated if changed during refinement -- `body_markdown`: Updated with refined content -- `acceptance_criteria`: Updated if extracted/refined (provider-specific mapping) -- `story_points`: Updated if extracted/refined (provider-specific mapping) -- `business_value`: Updated if extracted/refined (provider-specific mapping) -- `priority`: Updated if extracted/refined (provider-specific mapping) -- `value_points`: Updated if calculated (SAFe: business_value / story_points) -- `work_item_type`: Updated if extracted/refined (provider-specific mapping) - -**Fields that will be PRESERVED** (not modified): - -- `assignees`: Preserved -- `tags`: Preserved -- `state`: Preserved (original state maintained) -- `sprint`: Preserved (if present) -- `release`: Preserved (if present) -- `iteration`: Preserved (if present) -- `area`: Preserved (if present) -- `source_state`: Preserved for cross-adapter state mapping (stored in bundle entries) -- All other metadata: Preserved in provider_fields - -**Provider-Specific Field Mapping**: - -- **GitHub**: Fields are extracted from markdown body (headings, labels, etc.) and mapped to canonical fields -- **ADO**: Fields are extracted from separate ADO fields (System.Description, System.AcceptanceCriteria, Microsoft.VSTS.Common.StoryPoints, etc.) and mapped to canonical fields -- **Custom Mapping**: ADO supports custom field mapping via `.specfact/templates/backlog/field_mappings/ado_custom.yaml` or `SPECFACT_ADO_CUSTOM_MAPPING` environment variable - -**Cross-Adapter State Preservation**: - -- When items are imported into bundles, the original `source_state` (e.g., "open", "closed", "New", "Active") is stored in `source_metadata["source_state"]` -- During cross-adapter export (e.g., GitHub → ADO), the `source_state` is used to determine the correct target state -- Generic state mapping ensures state is correctly translated between any adapter pair using OpenSpec as intermediate format -- This ensures closed GitHub issues sync to ADO as "Closed", and open GitHub issues sync to ADO as "New" - -**OpenSpec Comment Integration**: - -- When `--openspec-comment` is used, a structured comment is added to the backlog item -- The comment includes: Change ID, template used, confidence score, refinement timestamp -- Original body is preserved; comment provides OpenSpec reference for cross-sync - -**Cross-Adapter State Mapping**: - -- When refining items that will be synced across adapters (e.g., GitHub ↔ ADO), state is preserved using generic mapping -- Generic state mapping uses OpenSpec as intermediate format: - - Source adapter state → OpenSpec status → Target adapter state - - Example: GitHub "open" → OpenSpec "proposed" → ADO "New" - - Example: GitHub "closed" → OpenSpec "applied" → ADO "Closed" -- State preservation: Original `source_state` is stored in bundle entries and used during cross-adapter export -- Bidirectional mapping: Works in both directions (GitHub → ADO and ADO → GitHub) -- State mapping is automatic during `sync bridge` operations when `source_state` and `source_type` are present - -## Architecture Note - -SpecFact CLI follows a CLI-first architecture: - -- SpecFact CLI generates prompts/instructions for IDE AI copilots -- IDE AI copilots execute those instructions using their native LLM -- IDE AI copilots feed results back to SpecFact CLI -- SpecFact CLI validates and processes the results -- SpecFact CLI does NOT directly invoke LLM APIs - -## Expected Output - -### Success (Preview Mode) - -```text -✓ Refinement completed (Preview Mode) - -Found 5 backlog items -Limited to 3 items (found 5 total) -Refined: 3 -Skipped: 0 - -Preview mode: Refinement will NOT be written to backlog -Use --write flag to explicitly opt-in to writeback -``` - -### Success (Cancelled Session) - -```text -Session cancelled by user - -Found 5 backlog items -Refined: 1 -Skipped: 1 -``` - -### Success (Write Mode) - -```text -✓ Refinement completed and written to backlog - -Found 5 backlog items -Refined: 3 -Skipped: 2 - -Items updated in remote backlog: - - #123: User Story Template Applied - - #124: Defect Template Applied - - #125: Spike Template Applied -``` - -## Common Patterns - -```bash -# Refine GitHub issues with feature label (requires repo-owner and repo-name) -/specfact.backlog-refine --adapter github --repo-owner nold-ai --repo-name specfact-cli --labels feature - -# Refine ADO work items (Azure DevOps Services - cloud) with full iteration path -/specfact.backlog-refine --adapter ado --ado-org my-org --ado-project my-project --sprint "MyProject\\Sprint 1" - -# Refine ADO work items using current active iteration (sprint omitted) -/specfact.backlog-refine --adapter ado --ado-org my-org --ado-project my-project --ado-team "My Team" --state Active - -# Refine ADO work items (Azure DevOps Server - on-premise, collection in base_url) -/specfact.backlog-refine --adapter ado --ado-base-url "https://devops.company.com/tfs/DefaultCollection" --ado-project my-project --state Active - -# Refine ADO work items (Azure DevOps Server - on-premise, collection provided) -/specfact.backlog-refine --adapter ado --ado-base-url "https://devops.company.com" --ado-org "DefaultCollection" --ado-project my-project --state Active - -# Refine with batch limit (process max 10 items) -/specfact.backlog-refine --adapter github --repo-owner nold-ai --repo-name specfact-cli --limit 10 --labels feature - -# Refine with case-insensitive filters -/specfact.backlog-refine --adapter ado --ado-org my-org --ado-project my-project --state "new" --assignee "jane doe" - -# Refine with Scrum framework and Product Owner persona -/specfact.backlog-refine --adapter github --repo-owner nold-ai --repo-name specfact-cli --framework scrum --persona product-owner - -# Preview refinement without writing -/specfact.backlog-refine --adapter github --repo-owner nold-ai --repo-name specfact-cli --preview - -# Write refinement to backlog with OpenSpec comment (explicit opt-in) -/specfact.backlog-refine --adapter github --repo-owner nold-ai --repo-name specfact-cli --write --openspec-comment - -# Check Definition of Ready before refinement -/specfact.backlog-refine --adapter github --repo-owner nold-ai --repo-name specfact-cli --check-dor --labels feature - -# Refine and import to OpenSpec bundle -/specfact.backlog-refine --adapter github --repo-owner nold-ai --repo-name specfact-cli --bundle my-project --auto-bundle --state open - -# Cross-adapter sync workflow: Refine GitHub → Sync to ADO (with state preservation) -/specfact.backlog-refine --adapter github --repo-owner nold-ai --repo-name specfact-cli --write --labels feature -# Then sync to ADO (state will be automatically mapped: open → New, closed → Closed) -# specfact sync bridge --adapter ado --ado-org my-org --ado-project my-project --mode bidirectional - -# Cross-adapter sync workflow: Refine ADO → Sync to GitHub (with state preservation) -/specfact.backlog-refine --adapter ado --ado-org my-org --ado-project my-project --write --state Active -# Then sync to GitHub (state will be automatically mapped: New → open, Closed → closed) -# specfact sync bridge --adapter github --repo-owner my-org --repo-name my-repo --mode bidirectional -``` - -## Troubleshooting - -### ADO API Errors - -**Error: "No HTTP resource was found that matches the request URI"** - -- **Cause**: Missing `api-version` parameter or incorrect URL format -- **Solution**: Ensure `api-version=7.1` is included in all ADO API URLs. Check base URL format for on-premise installations. - -**Error: "The requested resource does not support http method 'GET'"** - -- **Cause**: Attempting to use GET on WIQL endpoint (which requires POST) -- **Solution**: WIQL queries must use POST method with JSON body containing the query. This is handled automatically by SpecFact CLI. - -**Error: Organization removed from request string** - -- **Cause**: Incorrect base URL format (may already include organization/collection) -- **Solution**: For on-premise, check if base URL already includes collection. If yes, omit `--ado-org` or adjust base URL accordingly. - -**Error: "Azure DevOps API token required"** - -- **Cause**: Missing authentication token -- **Solution**: Provide token via `--ado-token`, `AZURE_DEVOPS_TOKEN` environment variable, or use `specfact auth azure-devops` for device code flow. - -## Context - -{ARGS} diff --git a/resources/prompts/specfact.sync-backlog.md b/resources/prompts/specfact.sync-backlog.md deleted file mode 100644 index 83dc155e..00000000 --- a/resources/prompts/specfact.sync-backlog.md +++ /dev/null @@ -1,557 +0,0 @@ -# SpecFact Sync Backlog Command - -## User Input - -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). - -## Purpose - -Sync OpenSpec change proposals to DevOps backlog tools (GitHub Issues, ADO, Linear, Jira) with AI-assisted content sanitization. Supports export-only sync from OpenSpec change proposals to DevOps issues. - -**When to use:** Creating backlog issues from OpenSpec change proposals, syncing change status to DevOps tools, managing public vs internal issue content. - -**Quick:** `/specfact.sync-backlog --adapter github` or `/specfact.sync-backlog --sanitize --target-repo owner/repo` - -## Parameters - -### Target/Input - -- `--repo PATH` - Path to OpenSpec repository containing change proposals. Default: current directory (.) -- `--code-repo PATH` - Path to source code repository for code change detection (default: same as `--repo`). **Required when OpenSpec repository differs from source code repository.** For example, if OpenSpec proposals are in `specfact-cli-internal` but source code is in `specfact-cli`, use `--repo /path/to/specfact-cli-internal --code-repo /path/to/specfact-cli`. -- `--target-repo OWNER/REPO` - Target repository for issue creation (format: owner/repo). Default: same as code repository - -### Behavior/Options - -- `--sanitize/--no-sanitize` - Sanitize proposal content for public issues (default: auto-detect based on repo setup) - - Auto-detection: If code repo != planning repo → sanitize, if same repo → no sanitization - - `--sanitize`: Force sanitization (removes competitive analysis, internal strategy, implementation details) - - `--no-sanitize`: Skip sanitization (use full proposal content) - - **Proposal Filtering**: The sanitization flag also controls which proposals are synced: - - **Public repos** (`--sanitize`): Only syncs proposals with status `"applied"` (archived/completed changes) - - **Internal repos** (`--no-sanitize`): Syncs all active proposals (`"proposed"`, `"in-progress"`, `"applied"`, `"deprecated"`, `"discarded"`) - - Filtering prevents premature exposure of work-in-progress proposals to public repositories -- `--interactive` - Interactive mode for AI-assisted sanitization (requires slash command) - - Enables interactive change selection - - Enables per-change sanitization selection - - Enables LLM review workflow for sanitized proposals -- `--change-ids IDS` - Comma-separated list of change proposal IDs to export (default: all active proposals) - - Example: `--change-ids add-devops-backlog-tracking,add-change-tracking-datamodel` - - Only used in non-interactive mode (interactive mode prompts for selection) -- `--export-to-tmp` - Export proposal content to temporary file for LLM review (sanitization workflow) - - Creates `/tmp/specfact-proposal-<change-id>.md` for each proposal - - Used internally by slash command for sanitization review -- `--import-from-tmp` - Import sanitized content from temporary file (sanitization workflow) - - Reads `/tmp/specfact-proposal-<change-id>-sanitized.md` for each proposal - - Used internally by slash command after LLM review -- `--tmp-file PATH` - Specify temporary file path (used with --export-to-tmp or --import-from-tmp) - - Default: `/tmp/specfact-proposal-<change-id>.md` or `/tmp/specfact-proposal-<change-id>-sanitized.md` - -**Exact tmp structure contract (`sync bridge --import-from-tmp`)**: - -- Preserve proposal heading and section headers exactly: - - `# Change: <title>` - - `## Why` - - `## What Changes` -- Keep a blank line after each section header (`## Why` and `## What Changes`) before content. -- Do not rename, remove, or reorder these headers. -- Keep sanitized content inside `## Why` and `## What Changes` sections only. -- Do not add extra top-level sections before, between, or after these sections. -- If headers are missing or renamed, parser extraction for rationale/description will be incomplete. - -Exact sanitized tmp example: - -```markdown -# Change: Improve backlog refinement mapping - -## Why - -Short rationale text. - -## What Changes - -Sanitized proposal description text. -``` - -### Code Change Tracking (Advanced) - -- `--track-code-changes/--no-track-code-changes` - Detect code changes (git commits, file modifications) and add progress comments to existing issues (default: False) - - **Repository Selection**: Uses `--code-repo` if provided, otherwise uses `--repo` for code change detection - - **Git Commit Detection**: Searches git log for commits mentioning the change proposal ID (e.g., `add-code-change-tracking`) - - **File Change Tracking**: Extracts files modified in detected commits - - **Progress Comment Generation**: Formats comment with commit details and file changes - - **Duplicate Prevention**: Checks against existing comments to avoid duplicates - - **Source Tracking Update**: Updates `proposal.md` with progress metadata -- `--add-progress-comment/--no-add-progress-comment` - Add manual progress comment to existing issues without code change detection (default: False) -- `--update-existing/--no-update-existing` - Update existing issue bodies when proposal content changes (default: False for safety). Uses content hash to detect changes. - -### Advanced/Configuration - -- `--adapter TYPE` - DevOps adapter type (github, ado, linear, jira). Default: github - -**GitHub Adapter Options:** - -- `--repo-owner OWNER` - Repository owner (for GitHub adapter). Optional, can use bridge config -- `--repo-name NAME` - Repository name (for GitHub adapter). Optional, can use bridge config -- `--github-token TOKEN` - GitHub API token (optional, uses GITHUB_TOKEN env var or gh CLI if not provided) -- `--use-gh-cli/--no-gh-cli` - Use GitHub CLI (`gh auth token`) to get token automatically (default: True). Useful in enterprise environments where PAT creation is restricted - -**Azure DevOps Adapter Options:** - -- `--ado-org ORG` - Azure DevOps organization (required for ADO adapter) -- `--ado-project PROJECT` - Azure DevOps project (required for ADO adapter) -- `--ado-base-url URL` - Azure DevOps base URL (optional, defaults to <https://dev.azure.com>). Use for Azure DevOps Server (on-prem) -- `--ado-token TOKEN` - Azure DevOps PAT (optional, uses AZURE_DEVOPS_TOKEN env var if not provided). Requires Work Items (Read & Write) permissions -- `--ado-work-item-type TYPE` - Azure DevOps work item type (optional, derived from process template if not provided). Examples: 'User Story', 'Product Backlog Item', 'Bug' - -## Workflow - -### Step 1: Parse Arguments - -- Extract repository path (default: current directory) -- Extract adapter type (default: github) -- Extract sanitization preference (default: auto-detect) -- Extract target repository (default: same as code repo) - -### Step 2: Interactive Change Selection (Slash Command Only) - -**When using slash command** (`/specfact.sync-backlog`), provide interactive selection: - -1. **List available change proposals**: - - Read OpenSpec change proposals from `openspec/changes/` (including archived proposals) - - Display list with: change ID, title, status, existing issue (if any) - - Format: `[1] add-devops-backlog-tracking (applied) - Issue #17` - - Format: `[2] add-change-tracking-datamodel (proposed) - No issue` - - **Note**: When `--sanitize` is used, only proposals with status `"applied"` will be synced to public repos - -2. **User selection**: - - Prompt: "Select changes to export (comma-separated numbers, 'all', or 'none'):" - - Parse selection (e.g., "1,3" or "all") - - Validate selection against available proposals - -3. **Per-change sanitization selection**: - - For each selected change, prompt: "Sanitize '[change-title]'? (y/n/auto):" - - `y`: Force sanitization - - `n`: Skip sanitization - - `auto`: Use auto-detection (code repo != planning repo) - - Store selection: `{change_id: sanitize_choice}` - -**When using CLI directly** (non-interactive): - -- **Public repos** (`--sanitize`): Only exports proposals with status `"applied"` (archived/completed) -- **Internal repos** (`--no-sanitize`): Exports all active proposals regardless of status -- Use `--sanitize/--no-sanitize` flag to control filtering behavior -- No per-change selection - -### Step 3: Execute CLI (Initial Pass) - -**For non-sanitized proposals** (direct export): - -```bash -# GitHub adapter -specfact sync bridge --adapter github --mode export-only --repo <openspec-path> \ - --no-sanitize --change-ids <id1,id2> \ - [--code-repo <source-code-path>] \ - [--track-code-changes] [--add-progress-comment] \ - [--target-repo <owner/repo>] [--repo-owner <owner>] [--repo-name <name>] \ - [--github-token <token>] [--use-gh-cli] - -# Azure DevOps adapter -specfact sync bridge --adapter ado --mode export-only --repo <openspec-path> \ - --no-sanitize --change-ids <id1,id2> \ - [--code-repo <source-code-path>] \ - [--track-code-changes] [--add-progress-comment] \ - --ado-org <org> --ado-project <project> \ - [--ado-token <token>] [--ado-base-url <url>] [--ado-work-item-type <type>] -``` - -**For sanitized proposals** (requires LLM review): - -```bash -# Step 3a: Export to temporary file for LLM review (GitHub) -specfact sync bridge --adapter github --mode export-only --repo <openspec-path> \ - --sanitize --change-ids <id1,id2> \ - [--code-repo <source-code-path>] \ - --export-to-tmp --tmp-file /tmp/specfact-proposal-<change-id>.md \ - [--target-repo <owner/repo>] [--repo-owner <owner>] [--repo-name <name>] \ - [--github-token <token>] [--use-gh-cli] - -# Step 3a: Export to temporary file for LLM review (ADO) -specfact sync bridge --adapter ado --mode export-only --repo <openspec-path> \ - --sanitize --change-ids <id1,id2> \ - [--code-repo <source-code-path>] \ - --export-to-tmp --tmp-file /tmp/specfact-proposal-<change-id>.md \ - --ado-org <org> --ado-project <project> \ - [--ado-token <token>] [--ado-base-url <url>] -``` - -**Note**: When `--code-repo` is provided, code change detection uses that repository. Otherwise, code changes are detected in the OpenSpec repository (`--repo`). - -### Step 4: LLM Sanitization Review (Slash Command Only, For Sanitized Proposals) - -**Only execute if sanitization is required**: - -1. **Read temporary file**: - - Read `/tmp/specfact-proposal-<change-id>.md` for each sanitized proposal - - Display original content to user - -2. **LLM sanitization**: - - Review proposal content for: - - Competitive analysis sections (remove) - - Market positioning statements (remove) - - Implementation details (file paths, code structure - remove or generalize) - - Effort estimates and timelines (remove) - - Internal strategy sections (remove) - - Preserve: - - User-facing value propositions - - High-level feature descriptions - - Acceptance criteria (user-facing) - - External documentation links - -3. **Generate sanitized content**: - - Create sanitized version with removed sections/patterns - - Write to `/tmp/specfact-proposal-<change-id>-sanitized.md` - - Display diff (original vs sanitized) for user review - -4. **User approval**: - - Prompt: "Approve sanitized content? (y/n/edit):" - - `y`: Proceed to Step 5 - - `n`: Skip this proposal - - `edit`: Allow user to manually edit sanitized file, then proceed - -### Step 5: Execute CLI (Final Export) - -**For sanitized proposals** (after LLM review): - -```bash -# Step 5a: Import sanitized content from temporary file (GitHub) -specfact sync bridge --adapter github --mode export-only --repo <path> \ - --import-from-tmp --tmp-file /tmp/specfact-proposal-<change-id>-sanitized.md \ - --change-ids <id1,id2> \ - [--target-repo <owner/repo>] [--repo-owner <owner>] [--repo-name <name>] \ - [--github-token <token>] [--use-gh-cli] - -# Step 5a: Import sanitized content from temporary file (ADO) -specfact sync bridge --adapter ado --mode export-only --repo <path> \ - --import-from-tmp --tmp-file /tmp/specfact-proposal-<change-id>-sanitized.md \ - --change-ids <id1,id2> \ - --ado-org <org> --ado-project <project> \ - [--ado-token <token>] [--ado-base-url <url>] -``` - -**For non-sanitized proposals** (already exported in Step 3): - -- No additional CLI call needed - -### Step 6: Present Results - -- Display sync results (issues created/updated) -- Show issue URLs and numbers -- Indicate sanitization status (if applied) -- List which proposals were sanitized vs exported directly -- **Show code change tracking results** (if `--track-code-changes` was enabled): - - Number of commits detected - - Number of progress comments added - - Repository used for code change detection (`--code-repo` or `--repo`) -- **Show filtering warnings** (if proposals were filtered out due to status) - - Example: `⚠ Filtered out 2 proposal(s) with non-applied status (public repos only sync archived/completed proposals)` -- Present any warnings or errors - -## CLI Enforcement - -**CRITICAL**: Always use SpecFact CLI commands. See [CLI Enforcement Rules](./shared/cli-enforcement.md) for details. - -**Rules:** - -- Execute CLI first - never create artifacts directly -- Use `--no-interactive` flag in CI/CD environments -- Never modify `.specfact/` or `openspec/` directly -- Use CLI output as grounding for validation -- Code generation requires LLM (only via AI IDE slash prompts, not CLI-only) - -## Dual-Stack Workflow (Copilot Mode) - -When in copilot mode, follow this workflow: - -### Phase 1: Interactive Selection (Slash Command Only) - -**Purpose**: Allow user to select which changes to export and sanitization preferences - -**What to do**: - -1. **List available proposals**: - - Read `openspec/changes/` directory (including `archive/` subdirectory) - - Parse `proposal.md` files to extract: change_id, title, status - - Check for existing issues via `source_tracking` section - - Display numbered list to user - - **Note**: When `--sanitize` is used, only proposals with status `"applied"` will be available for public repos - -2. **User selection**: - - Prompt for change selection (comma-separated numbers, 'all', 'none') - - For each selected change, prompt for sanitization preference (y/n/auto) - - Store selections: `{change_id: {selected: bool, sanitize: bool|None}}` - -**Output**: Dictionary mapping change IDs to selection and sanitization preferences - -### Phase 2: CLI Export to Temporary Files (For Sanitized Proposals Only) - -**Purpose**: Export proposal content to temporary files for LLM review - -**When**: Only for proposals where `sanitize=True` - -**What to do**: - -```bash -# For each sanitized proposal, export to temp file (GitHub) -specfact sync bridge --adapter github --mode export-only --repo <openspec-path> \ - --change-ids <change-id> --export-to-tmp --tmp-file /tmp/specfact-proposal-<change-id>.md \ - [--code-repo <source-code-path>] \ - [--repo-owner <owner>] [--repo-name <name>] [--github-token <token>] [--use-gh-cli] - -# For each sanitized proposal, export to temp file (ADO) -specfact sync bridge --adapter ado --mode export-only --repo <openspec-path> \ - --change-ids <change-id> --export-to-tmp --tmp-file /tmp/specfact-proposal-<change-id>.md \ - [--code-repo <source-code-path>] \ - --ado-org <org> --ado-project <project> [--ado-token <token>] [--ado-base-url <url>] -``` - -**Capture**: - -- Temporary file paths for each proposal -- Original proposal content (for comparison) - -**What NOT to do**: - -- ❌ Create GitHub issues directly (wait for sanitization review) -- ❌ Skip LLM review for sanitized proposals - -### Phase 3: LLM Sanitization Review (For Sanitized Proposals Only) - -**Purpose**: Review and sanitize proposal content before creating public issues - -**When**: Only for proposals where `sanitize=True` - -**What to do**: - -1. **Read temporary file**: - - Read `/tmp/specfact-proposal-<change-id>.md` for each sanitized proposal - - Display original content to user - -2. **LLM sanitization**: - - Review proposal content section by section - - Remove: - - Competitive analysis sections (`## Competitive Analysis`) - - Market positioning statements (`## Market Positioning`) - - Implementation details (file paths like `src/specfact_cli/...`, code structure) - - Effort estimates and timelines - - Internal strategy sections - - Preserve: - - User-facing value propositions - - High-level feature descriptions (without file paths) - - Acceptance criteria (user-facing) - - External documentation links - -3. **Generate sanitized content**: - - Create sanitized version with removed sections/patterns - - Write to `/tmp/specfact-proposal-<change-id>-sanitized.md` - - Display diff (original vs sanitized) for user review - -4. **User approval**: - - Prompt: "Approve sanitized content for '[change-title]'? (y/n/edit):" - - `y`: Proceed to Phase 4 - - `n`: Skip this proposal (don't create issue) - - `edit`: Allow user to manually edit sanitized file, then proceed - -**Output**: Sanitized content files in `/tmp/specfact-proposal-<change-id>-sanitized.md` - -**What NOT to do**: - -- ❌ Create GitHub issues directly (use CLI in Phase 4) -- ❌ Modify original proposal files -- ❌ Skip user approval step - -### Phase 4: CLI Direct Export (For Non-Sanitized Proposals) - -**Purpose**: Export proposals that don't require sanitization - -**When**: For proposals where `sanitize=False` - -**What to do**: - -```bash -# Export non-sanitized proposals directly (GitHub) -specfact sync bridge --adapter github --mode export-only --repo <openspec-path> \ - --change-ids <id1,id2> --no-sanitize \ - [--code-repo <source-code-path>] \ - [--track-code-changes] [--add-progress-comment] \ - [--repo-owner <owner>] [--repo-name <name>] [--github-token <token>] [--use-gh-cli] - -# Export non-sanitized proposals directly (ADO) -specfact sync bridge --adapter ado --mode export-only --repo <openspec-path> \ - --change-ids <id1,id2> --no-sanitize \ - [--code-repo <source-code-path>] \ - [--track-code-changes] [--add-progress-comment] \ - --ado-org <org> --ado-project <project> [--ado-token <token>] [--ado-base-url <url>] -``` - -**Result**: Issues created directly without LLM review - -### Phase 5: CLI Import Sanitized Content (For Sanitized Proposals Only) - -**Purpose**: Create GitHub issues from LLM-reviewed sanitized content - -**When**: Only for proposals where `sanitize=True` and user approved - -**What to do**: - -```bash -# For each approved sanitized proposal, import from temp file and create issue (GitHub) -specfact sync bridge --adapter github --mode export-only --repo <openspec-path> \ - --change-ids <change-id> --import-from-tmp --tmp-file /tmp/specfact-proposal-<change-id>-sanitized.md \ - [--code-repo <source-code-path>] \ - [--track-code-changes] [--add-progress-comment] \ - [--repo-owner <owner>] [--repo-name <name>] [--github-token <token>] [--use-gh-cli] - -# For each approved sanitized proposal, import from temp file and create work item (ADO) -specfact sync bridge --adapter ado --mode export-only --repo <openspec-path> \ - --change-ids <change-id> --import-from-tmp --tmp-file /tmp/specfact-proposal-<change-id>-sanitized.md \ - [--code-repo <source-code-path>] \ - [--track-code-changes] [--add-progress-comment] \ - --ado-org <org> --ado-project <project> [--ado-token <token>] [--ado-base-url <url>] -``` - -**Result**: Issues created with sanitized content - -**What NOT to do**: - -- ❌ Create GitHub issues directly via API (use CLI command) -- ❌ Skip CLI validation -- ❌ Modify `.specfact/` or `openspec/` folders directly - -### Phase 6: Cleanup and Results - -**Purpose**: Clean up temporary files and present results - -**What to do**: - -1. **Cleanup**: - - Remove temporary files: `/tmp/specfact-proposal-*.md` - - Remove sanitized files: `/tmp/specfact-proposal-*-sanitized.md` - -2. **Present results**: - - Display sync results (issues created/updated) - - Show issue URLs and numbers - - Indicate which proposals were sanitized vs exported directly - - **Show code change tracking results** (if `--track-code-changes` was enabled): - - Number of commits detected per proposal - - Number of progress comments added per issue - - Repository used for code change detection (`--code-repo` or `--repo`) - - Example: `✓ Detected 3 commits for 'add-feature-x', added 1 progress comment to issue #123` - - **Show filtering warnings** (if proposals were filtered out): - - Public repos: `⚠ Filtered out N proposal(s) with non-applied status (public repos only sync archived/completed proposals)` - - Internal repos: `⚠ Filtered out N proposal(s) without source tracking entry and inactive status` - - Present any warnings or errors - -**Note**: If code generation is needed, use the validation loop pattern (see [CLI Enforcement Rules](./shared/cli-enforcement.md#standard-validation-loop-pattern-for-llm-generated-code)) - -## Expected Output - -### Success - -```text -✓ Successfully synced 3 change proposals - -Adapter: github -Repository: nold-ai/specfact-cli-internal -Code Repository: nold-ai/specfact-cli (separate repo) - -Issues Created: - - #14: Add DevOps Backlog Tracking Integration - - #15: Add Change Tracking Data Model - - #16: Implement OpenSpec Bridge Adapter - -Sanitization: Applied (different repos detected) -Issue IDs saved to OpenSpec proposal files -``` - -### Success (With Code Change Tracking) - -```text -✓ Successfully synced 3 change proposals - -Adapter: github -Repository: nold-ai/specfact-cli-internal -Code Repository: nold-ai/specfact-cli (separate repo) - -Issues Created: - - #14: Add DevOps Backlog Tracking Integration - - #15: Add Change Tracking Data Model - - #16: Implement OpenSpec Bridge Adapter - -Code Change Tracking: - - Detected 5 commits for 'add-devops-backlog-tracking' - - Added 1 progress comment to issue #14 - - Detected 3 commits for 'add-change-tracking-datamodel' - - Added 1 progress comment to issue #15 - - No new commits detected for 'implement-openspec-bridge-adapter' - -Sanitization: Applied (different repos detected) -Issue IDs saved to OpenSpec proposal files -``` - -### Error (Missing Token) - -```text -✗ Sync failed: Missing GitHub API token -Provide token via --github-token, GITHUB_TOKEN env var, or --use-gh-cli -``` - -### Warning (Sanitization Applied) - -```text -⚠ Content sanitization applied (code repo != planning repo) -Competitive analysis and internal strategy sections removed -``` - -### Warning (Proposals Filtered - Public Repo) - -```text -✓ Successfully synced 1 change proposals -⚠ Filtered out 2 proposal(s) with non-applied status (public repos only sync archived/completed proposals, regardless of source tracking). Only 1 applied proposal(s) will be synced. -``` - -### Warning (Proposals Filtered - Internal Repo) - -```text -✓ Successfully synced 3 change proposals -⚠ Filtered out 1 proposal(s) without source tracking entry for target repo and inactive status. Only 3 proposal(s) will be synced. -``` - -## Common Patterns - -```bash -# Public repo: only syncs "applied" proposals (archived changes) -/specfact.sync-backlog --adapter github --sanitize --target-repo nold-ai/specfact-cli - -# Internal repo: syncs all active proposals (proposed, in-progress, applied, etc.) -/specfact.sync-backlog --adapter github --no-sanitize --target-repo nold-ai/specfact-cli-internal - -# Auto-detect sanitization (filters based on repo setup) -/specfact.sync-backlog --adapter github - -# Explicit repository configuration (GitHub) -/specfact.sync-backlog --adapter github --repo-owner nold-ai --repo-name specfact-cli-internal - -# Azure DevOps adapter (requires org and project) -/specfact.sync-backlog --adapter ado --ado-org my-org --ado-project my-project - -# Use GitHub CLI for token (enterprise-friendly) -/specfact.sync-backlog --adapter github --use-gh-cli -``` - -## Context - -{ARGS} diff --git a/resources/templates/backlog/defaults/defect_v1.yaml b/resources/templates/backlog/defaults/defect_v1.yaml deleted file mode 100644 index 674e5362..00000000 --- a/resources/templates/backlog/defaults/defect_v1.yaml +++ /dev/null @@ -1,22 +0,0 @@ -template_id: defect_v1 -name: Defect -description: Standard defect/bug template with reproduction steps and expected behavior -scope: corporate -required_sections: - - Description - - Steps to Reproduce - - Expected Behavior - - Actual Behavior -optional_sections: - - Environment - - Screenshots - - Related Issues -body_patterns: - steps_to_reproduce: "[Ss]teps? to [Rr]eproduce" - expected_behavior: "[Ee]xpected [Bb]ehavior" - actual_behavior: "[Aa]ctual [Bb]ehavior" -title_patterns: - - "^.*[Bb]ug.*$" - - "^.*[Dd]efect.*$" - - "^.*[Ii]ssue.*$" -schema_ref: openspec/templates/defect_v1/ diff --git a/resources/templates/backlog/defaults/enabler_v1.yaml b/resources/templates/backlog/defaults/enabler_v1.yaml deleted file mode 100644 index 5e704b01..00000000 --- a/resources/templates/backlog/defaults/enabler_v1.yaml +++ /dev/null @@ -1,21 +0,0 @@ -template_id: enabler_v1 -name: Enabler -description: Enabler work template for infrastructure and technical improvements -scope: corporate -required_sections: - - Objective - - Technical Approach - - Success Criteria -optional_sections: - - Dependencies - - Risks - - Timeline -body_patterns: - objective: "[Oo]bjective" - technical_approach: "[Tt]echnical [Aa]pproach" - success_criteria: "[Ss]uccess [Cc]riteria" -title_patterns: - - "^.*[Ee]nabler.*$" - - "^.*[Ii]nfrastructure.*$" - - "^.*[Tt]echnical [Dd]ebt.*$" -schema_ref: openspec/templates/enabler_v1/ diff --git a/resources/templates/backlog/defaults/spike_v1.yaml b/resources/templates/backlog/defaults/spike_v1.yaml deleted file mode 100644 index 2f9ffec9..00000000 --- a/resources/templates/backlog/defaults/spike_v1.yaml +++ /dev/null @@ -1,20 +0,0 @@ -template_id: spike_v1 -name: Spike -description: Research spike template for investigating technical feasibility -scope: corporate -required_sections: - - Research Question - - Investigation Approach - - Findings -optional_sections: - - Recommendations - - Time Box - - Related Work -body_patterns: - research_question: "[Rr]esearch [Qq]uestion" - investigation_approach: "[Ii]nvestigation [Aa]pproach" - findings: "[Ff]indings" -title_patterns: - - "^.*[Ss]pike.*$" - - "^.*[Rr]esearch.*$" -schema_ref: openspec/templates/spike_v1/ diff --git a/resources/templates/backlog/defaults/user_story_v1.yaml b/resources/templates/backlog/defaults/user_story_v1.yaml deleted file mode 100644 index bfd90c35..00000000 --- a/resources/templates/backlog/defaults/user_story_v1.yaml +++ /dev/null @@ -1,19 +0,0 @@ -template_id: user_story_v1 -name: User Story -description: Standard user story template with As a/I want/So that structure -scope: corporate -required_sections: - - As a - - I want - - So that - - Acceptance Criteria -optional_sections: - - Notes - - Dependencies -body_patterns: - as_a: "As a [^,]+ I want" - so_that: "So that [^,]+" -title_patterns: - - "^.*[Uu]ser [Ss]tory.*$" - - "^.*[Uu]ser.*[Ss]tory.*$" -schema_ref: openspec/templates/user_story_v1/ diff --git a/resources/templates/backlog/field_mappings/ado_agile.yaml b/resources/templates/backlog/field_mappings/ado_agile.yaml deleted file mode 100644 index 22e94ac5..00000000 --- a/resources/templates/backlog/field_mappings/ado_agile.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# ADO Agile process template field mapping -# Optimized for Agile process template with User Stories, Story Points - -framework: agile - -# Field mappings: ADO field name -> canonical field name -field_mappings: - System.Description: description - System.AcceptanceCriteria: acceptance_criteria - Microsoft.VSTS.Common.AcceptanceCriteria: acceptance_criteria # Alternative field name - Microsoft.VSTS.Scheduling.StoryPoints: story_points - Microsoft.VSTS.Common.BusinessValue: business_value - Microsoft.VSTS.Common.Priority: priority - System.WorkItemType: work_item_type - System.IterationPath: iteration - System.AreaPath: area - -# Work item type mappings: ADO work item type -> canonical work item type -work_item_type_mappings: - User Story: User Story - Bug: Bug - Task: Task - Epic: Epic - Feature: Feature diff --git a/resources/templates/backlog/field_mappings/ado_default.yaml b/resources/templates/backlog/field_mappings/ado_default.yaml deleted file mode 100644 index 74dd3198..00000000 --- a/resources/templates/backlog/field_mappings/ado_default.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Default ADO field mapping template -# Generic mappings that work across most ADO process templates - -framework: default - -# Field mappings: ADO field name -> canonical field name -field_mappings: - System.Description: description - System.AcceptanceCriteria: acceptance_criteria - Microsoft.VSTS.Common.AcceptanceCriteria: acceptance_criteria # Alternative field name - Microsoft.VSTS.Common.StoryPoints: story_points - Microsoft.VSTS.Scheduling.StoryPoints: story_points - Microsoft.VSTS.Common.BusinessValue: business_value - Microsoft.VSTS.Common.Priority: priority - System.WorkItemType: work_item_type - -# Work item type mappings: ADO work item type -> canonical work item type -work_item_type_mappings: - Product Backlog Item: User Story - User Story: User Story - Feature: Feature - Epic: Epic - Task: Task - Bug: Bug diff --git a/resources/templates/backlog/field_mappings/ado_kanban.yaml b/resources/templates/backlog/field_mappings/ado_kanban.yaml deleted file mode 100644 index 4753282f..00000000 --- a/resources/templates/backlog/field_mappings/ado_kanban.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# ADO Kanban process template field mapping -# Optimized for Kanban workflow with work item types, state transitions, no sprint requirement - -framework: kanban - -# Field mappings: ADO field name -> canonical field name -field_mappings: - System.Description: description - System.AcceptanceCriteria: acceptance_criteria - Microsoft.VSTS.Common.AcceptanceCriteria: acceptance_criteria # Alternative field name - Microsoft.VSTS.Common.Priority: priority - System.WorkItemType: work_item_type - System.State: state - System.AreaPath: area - # Kanban doesn't require story points, but may have them - Microsoft.VSTS.Scheduling.StoryPoints: story_points - Microsoft.VSTS.Common.StoryPoints: story_points - -# Work item type mappings: ADO work item type -> canonical work item type -# Kanban supports various work item types without strict hierarchy -work_item_type_mappings: - User Story: User Story - Task: Task - Bug: Bug - Feature: Feature - Epic: Epic diff --git a/resources/templates/backlog/field_mappings/ado_safe.yaml b/resources/templates/backlog/field_mappings/ado_safe.yaml deleted file mode 100644 index 17c666f0..00000000 --- a/resources/templates/backlog/field_mappings/ado_safe.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# ADO SAFe process template field mapping -# Optimized for SAFe process template with Epic → Feature → Story → Task hierarchy, -# Value Points, WSJF prioritization - -framework: safe - -# Field mappings: ADO field name -> canonical field name -field_mappings: - System.Description: description - System.AcceptanceCriteria: acceptance_criteria - Microsoft.VSTS.Common.AcceptanceCriteria: acceptance_criteria # Alternative field name - Microsoft.VSTS.Scheduling.StoryPoints: story_points - Microsoft.VSTS.Common.BusinessValue: business_value - Microsoft.VSTS.Common.Priority: priority - System.WorkItemType: work_item_type - System.IterationPath: iteration - System.AreaPath: area - # SAFe-specific fields (if available) - Microsoft.VSTS.Common.ValueArea: value_points - Microsoft.VSTS.Common.Risk: priority - -# Work item type mappings: ADO work item type -> canonical work item type -# SAFe hierarchy: Epic → Feature → User Story → Task -work_item_type_mappings: - Epic: Epic - Feature: Feature - User Story: User Story - Task: Task - Bug: Bug diff --git a/resources/templates/backlog/field_mappings/ado_scrum.yaml b/resources/templates/backlog/field_mappings/ado_scrum.yaml deleted file mode 100644 index df055c51..00000000 --- a/resources/templates/backlog/field_mappings/ado_scrum.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# ADO Scrum process template field mapping -# Optimized for Scrum process template with Product Backlog Items, Story Points, Sprint tracking - -framework: scrum - -# Field mappings: ADO field name -> canonical field name -field_mappings: - System.Description: description - System.AcceptanceCriteria: acceptance_criteria - Microsoft.VSTS.Common.AcceptanceCriteria: acceptance_criteria # Alternative field name - Microsoft.VSTS.Scheduling.StoryPoints: story_points - Microsoft.VSTS.Common.BusinessValue: business_value - Microsoft.VSTS.Common.Priority: priority - System.WorkItemType: work_item_type - System.IterationPath: iteration - System.AreaPath: area - -# Work item type mappings: ADO work item type -> canonical work item type -work_item_type_mappings: - Product Backlog Item: User Story - Bug: Bug - Task: Task - Impediment: Task - Epic: Epic diff --git a/resources/templates/backlog/frameworks/safe/safe_feature_v1.yaml b/resources/templates/backlog/frameworks/safe/safe_feature_v1.yaml deleted file mode 100644 index df7d95d2..00000000 --- a/resources/templates/backlog/frameworks/safe/safe_feature_v1.yaml +++ /dev/null @@ -1,26 +0,0 @@ -template_id: safe_feature_v1 -name: SAFe Feature -description: Feature template optimized for Scaled Agile Framework (SAFe) with business value, acceptance criteria, and solution intent -scope: corporate -framework: safe -required_sections: - - Feature Name - - Business Value - - Acceptance Criteria - - Solution Intent -optional_sections: - - Dependencies - - Non-functional Requirements (NFRs) - - Risks - - Timeline - - Notes -body_patterns: - feature_name: "[Ff]eature [Nn]ame" - business_value: "[Bb]usiness [Vv]alue" - acceptance_criteria: "[Aa]cceptance [Cc]riteria" - solution_intent: "[Ss]olution [Ii]ntent" -title_patterns: - - "^.*[Ff]eature.*$" - - "^.*SAFe.*$" - - "^.*[Ff]eature:.*$" -schema_ref: openspec/templates/frameworks/safe/feature_v1/ diff --git a/resources/templates/backlog/frameworks/scrum/user_story_v1.yaml b/resources/templates/backlog/frameworks/scrum/user_story_v1.yaml deleted file mode 100644 index 4a14f5de..00000000 --- a/resources/templates/backlog/frameworks/scrum/user_story_v1.yaml +++ /dev/null @@ -1,23 +0,0 @@ -template_id: scrum_user_story_v1 -name: Scrum User Story -description: User story template optimized for Scrum framework with story points and sprint planning -scope: corporate -framework: scrum -required_sections: - - As a - - I want - - So that - - Acceptance Criteria - - Story Points -optional_sections: - - Notes - - Dependencies - - Definition of Done -body_patterns: - as_a: "As a [^,]+ I want" - so_that: "So that [^,]+" - story_points: "Story Points?:\\s*\\d+" -title_patterns: - - "^.*[Uu]ser [Ss]tory.*$" - - "^.*[Uu]ser.*[Ss]tory.*$" -schema_ref: openspec/templates/frameworks/scrum/user_story_v1/ diff --git a/resources/templates/backlog/personas/developer/developer_task_v1.yaml b/resources/templates/backlog/personas/developer/developer_task_v1.yaml deleted file mode 100644 index 33878b21..00000000 --- a/resources/templates/backlog/personas/developer/developer_task_v1.yaml +++ /dev/null @@ -1,28 +0,0 @@ -template_id: developer_task_v1 -name: Developer Task -description: Task template optimized for Developer persona with technical implementation details -scope: corporate -personas: - - developer -required_sections: - - Task Description - - Technical Approach - - Implementation Details - - Acceptance Criteria -optional_sections: - - Dependencies - - Testing Requirements - - Performance Considerations - - Security Considerations - - Notes -body_patterns: - task_description: "[Tt]ask [Dd]escription" - technical_approach: "[Tt]echnical [Aa]pproach" - implementation_details: "[Ii]mplementation [Dd]etails" - acceptance_criteria: "[Aa]cceptance [Cc]riteria" -title_patterns: - - "^.*[Tt]ask.*$" - - "^.*[Dd]evelop.*$" - - "^.*[Ii]mplement.*$" - - "^.*[Tt]ech.*$" -schema_ref: openspec/templates/personas/developer/task_v1/ diff --git a/resources/templates/backlog/personas/product-owner/user_story_v1.yaml b/resources/templates/backlog/personas/product-owner/user_story_v1.yaml deleted file mode 100644 index 5883f3cd..00000000 --- a/resources/templates/backlog/personas/product-owner/user_story_v1.yaml +++ /dev/null @@ -1,24 +0,0 @@ -template_id: product_owner_user_story_v1 -name: Product Owner User Story -description: User story template optimized for Product Owner persona with business value focus -scope: corporate -personas: - - product-owner -required_sections: - - As a - - I want - - So that - - Acceptance Criteria - - Business Value -optional_sections: - - Notes - - Dependencies - - Success Metrics -body_patterns: - as_a: "As a [^,]+ I want" - so_that: "So that [^,]+" - business_value: "Business Value?:\\s*.+" -title_patterns: - - "^.*[Uu]ser [Ss]tory.*$" - - "^.*[Uu]ser.*[Ss]tory.*$" -schema_ref: openspec/templates/personas/product-owner/user_story_v1/ diff --git a/resources/templates/backlog/providers/ado/work_item_v1.yaml b/resources/templates/backlog/providers/ado/work_item_v1.yaml deleted file mode 100644 index dc912f69..00000000 --- a/resources/templates/backlog/providers/ado/work_item_v1.yaml +++ /dev/null @@ -1,21 +0,0 @@ -template_id: ado_work_item_v1 -name: Azure DevOps Work Item -description: Work item template optimized for Azure DevOps with area path and iteration path support -scope: corporate -provider: ado -required_sections: - - Description - - Acceptance Criteria -optional_sections: - - Notes - - Dependencies - - Area Path - - Iteration Path -body_patterns: - description: "Description?:\\s*.+" - acceptance_criteria: "Acceptance Criteria?:\\s*.+" -title_patterns: - - "^.*[Ww]ork [Ii]tem.*$" - - "^.*[Uu]ser [Ss]tory.*$" - - "^.*[Bb]ug.*$" -schema_ref: openspec/templates/providers/ado/work_item_v1/ diff --git a/scripts/yaml-tools.sh b/scripts/yaml-tools.sh index ebbf4b61..89caf163 100755 --- a/scripts/yaml-tools.sh +++ b/scripts/yaml-tools.sh @@ -30,13 +30,17 @@ run_prettier_workflows() { run_yamllint() { # Lint only non-workflow YAML files, using root .yamllint - local files - files=$(git ls-files "*.yml" "*.yaml" | grep -v "^\.github/workflows/" || true) - if [[ -n "${files}" ]]; then + local files=() + while IFS= read -r file; do + [[ "${file}" =~ ^\.github/workflows/ ]] && continue + [[ -f "${file}" ]] || continue + files+=("${file}") + done < <(git ls-files "*.yml" "*.yaml") + if [[ ${#files[@]} -gt 0 ]]; then # Do not fail on warnings: print all findings, fail only when "error" severity is present set +e local out - out=$(yamllint -f standard -c "${REPO_ROOT}/.yamllint" ${files}) + out=$(yamllint -f standard -c "${REPO_ROOT}/.yamllint" "${files[@]}") local rc=$? set -e printf "%s\n" "$out" diff --git a/setup.py b/setup.py index 3c305a56..657597a7 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.40.1", + version="0.40.2", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index 3ebce126..fb8eb3a1 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.40.1" +__version__ = "0.40.2" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 68e2b918..01febbf9 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -42,6 +42,6 @@ def _bootstrap_bundle_paths() -> None: _bootstrap_bundle_paths() -__version__ = "0.40.1" +__version__ = "0.40.2" __all__ = ["__version__"] diff --git a/src/specfact_cli/backlog/__init__.py b/src/specfact_cli/backlog/__init__.py index 9de05317..6e20b922 100644 --- a/src/specfact_cli/backlog/__init__.py +++ b/src/specfact_cli/backlog/__init__.py @@ -1,24 +1,14 @@ -""" -Backlog refinement and template detection. - -This module provides AI-assisted backlog refinement with template detection -and matching capabilities. -""" +"""Shared backlog conversion helpers retained by core adapters.""" from __future__ import annotations -from specfact_cli.backlog.ai_refiner import BacklogAIRefiner from specfact_cli.backlog.converter import ( convert_ado_work_item_to_backlog_item, convert_github_issue_to_backlog_item, ) -from specfact_cli.backlog.template_detector import TemplateDetectionResult, TemplateDetector __all__ = [ - "BacklogAIRefiner", - "TemplateDetectionResult", - "TemplateDetector", "convert_ado_work_item_to_backlog_item", "convert_github_issue_to_backlog_item", ] diff --git a/src/specfact_cli/backlog/adapters/__init__.py b/src/specfact_cli/backlog/adapters/__init__.py index 6fb3fd9e..976d82dc 100644 --- a/src/specfact_cli/backlog/adapters/__init__.py +++ b/src/specfact_cli/backlog/adapters/__init__.py @@ -1,13 +1,8 @@ -""" -Backlog adapter implementations. - -This module provides backlog adapter implementations for various providers. -""" +"""Backlog adapter contracts retained by core provider integrations.""" from __future__ import annotations from specfact_cli.backlog.adapters.base import BacklogAdapter -from specfact_cli.backlog.adapters.local_yaml_adapter import LocalYAMLBacklogAdapter -__all__ = ["BacklogAdapter", "LocalYAMLBacklogAdapter"] +__all__ = ["BacklogAdapter"] diff --git a/src/specfact_cli/backlog/adapters/local_yaml_adapter.py b/src/specfact_cli/backlog/adapters/local_yaml_adapter.py deleted file mode 100644 index 4bb8d196..00000000 --- a/src/specfact_cli/backlog/adapters/local_yaml_adapter.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -Local YAML backlog adapter example. - -This module provides an example implementation of a backlog adapter that reads -from and writes to a local YAML file, demonstrating the extensibility of the -BacklogAdapter interface. -""" - -from __future__ import annotations - -from pathlib import Path - -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.backlog.adapters.base import BacklogAdapter -from specfact_cli.backlog.filters import BacklogFilters -from specfact_cli.backlog.formats.structured_format import StructuredFormat -from specfact_cli.models.backlog_item import BacklogItem -from specfact_cli.utils.yaml_utils import dump_yaml, load_yaml - - -class LocalYAMLBacklogAdapter(BacklogAdapter): - """ - Local YAML backlog adapter example. - - This adapter reads backlog items from `.specfact/backlog.yaml` and writes - updates back to the same file. This demonstrates how easy it is to add new - backlog sources using the BacklogAdapter interface. - """ - - def __init__(self, backlog_file: Path | None = None) -> None: - """ - Initialize local YAML adapter. - - Args: - backlog_file: Path to backlog YAML file (defaults to `.specfact/backlog.yaml`) - """ - if backlog_file is None: - backlog_file = Path(".specfact") / "backlog.yaml" - self.backlog_file = Path(backlog_file) - self._format = StructuredFormat(format_type="yaml") - - @beartype - @ensure(lambda result: isinstance(result, str) and len(result) > 0, "Must return non-empty adapter name") - def name(self) -> str: - """Get the adapter name.""" - return "local_yaml" - - @beartype - @require(lambda format_type: isinstance(format_type, str) and len(format_type) > 0, "Format type must be non-empty") - @ensure(lambda result: isinstance(result, bool), "Must return boolean") - def supports_format(self, format_type: str) -> bool: - """Check if adapter supports the specified format.""" - return format_type.lower() == "yaml" - - @beartype - @require(lambda filters: isinstance(filters, BacklogFilters), "Filters must be BacklogFilters instance") - @ensure(lambda result: isinstance(result, list), "Must return list of BacklogItem") - @ensure( - lambda result, filters: all(isinstance(item, BacklogItem) for item in result), "All items must be BacklogItem" - ) - def fetch_backlog_items(self, filters: BacklogFilters) -> list[BacklogItem]: - """ - Fetch backlog items from local YAML file. - - Reads items from `.specfact/backlog.yaml` and applies filters. - """ - if not self.backlog_file.exists(): - return [] - - # Load YAML file - data = load_yaml(self.backlog_file) - items_data = data.get("items", []) - - # Convert to BacklogItem instances - items: list[BacklogItem] = [] - for item_data in items_data: - try: - item = BacklogItem(**item_data) - items.append(item) - except Exception: - # Skip invalid items - continue - - # Apply filters - filtered_items = items - - if filters.state: - filtered_items = [item for item in filtered_items if item.state.lower() == filters.state.lower()] - - if filters.assignee: - filtered_items = [ - item - for item in filtered_items - if any(assignee.lower() == filters.assignee.lower() for assignee in item.assignees) - ] - - if filters.labels: - filtered_items = [item for item in filtered_items if any(label in item.tags for label in filters.labels)] - - if filters.iteration: - filtered_items = [item for item in filtered_items if item.iteration and item.iteration == filters.iteration] - - if filters.sprint: - filtered_items = [item for item in filtered_items if item.sprint and item.sprint == filters.sprint] - - if filters.release: - filtered_items = [item for item in filtered_items if item.release and item.release == filters.release] - - if filters.area: - # Area filtering not applicable for local YAML - pass - - if filters.search: - # Simple text search in title and body - search_lower = filters.search.lower() - filtered_items = [ - item - for item in filtered_items - if search_lower in item.title.lower() or search_lower in item.body_markdown.lower() - ] - - return filtered_items - - @beartype - @require(lambda item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @require( - lambda update_fields: update_fields is None or isinstance(update_fields, list), - "Update fields must be None or list", - ) - @ensure(lambda result: isinstance(result, BacklogItem), "Must return BacklogItem") - @ensure( - lambda result, item: result.id == item.id and result.provider == item.provider, - "Updated item must preserve id and provider", - ) - def update_backlog_item(self, item: BacklogItem, update_fields: list[str] | None = None) -> BacklogItem: - """ - Update a backlog item in local YAML file. - - Updates the item in `.specfact/backlog.yaml` and returns the updated item. - """ - # Ensure directory exists - self.backlog_file.parent.mkdir(parents=True, exist_ok=True) - - # Load existing items - if self.backlog_file.exists(): - data = load_yaml(self.backlog_file) - items_data = data.get("items", []) - else: - items_data = [] - - # Find and update item - updated = False - for i, existing_item_data in enumerate(items_data): - if existing_item_data.get("id") == item.id and existing_item_data.get("provider") == item.provider: - # Update item - if update_fields is None: - # Update all fields - items_data[i] = item.model_dump() - else: - # Update only specified fields - for field in update_fields: - if hasattr(item, field): - items_data[i][field] = getattr(item, field) - updated = True - break - - # If item not found, add it - if not updated: - items_data.append(item.model_dump()) - - # Save back to YAML file - data = {"items": items_data} - dump_yaml(data, self.backlog_file) - - return item diff --git a/src/specfact_cli/backlog/ai_refiner.py b/src/specfact_cli/backlog/ai_refiner.py deleted file mode 100644 index 36b6b18f..00000000 --- a/src/specfact_cli/backlog/ai_refiner.py +++ /dev/null @@ -1,523 +0,0 @@ -""" -Backlog refinement prompt generator and validator. - -This module generates prompts for IDE AI copilots to refine backlog items, -and validates/processes the refined content returned by the AI copilot. - -SpecFact CLI Architecture: -- SpecFact CLI generates prompts/instructions for IDE AI copilots -- IDE AI copilots execute those instructions using their native LLM -- IDE AI copilots feed results back to SpecFact CLI -- SpecFact CLI validates and processes the results -""" - -from __future__ import annotations - -import re - -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.backlog.template_detector import get_effective_required_sections -from specfact_cli.models.backlog_item import BacklogItem -from specfact_cli.templates.registry import BacklogTemplate - - -class RefinementResult: - """Result of AI refinement with refined content and confidence.""" - - def __init__( - self, - refined_body: str, - confidence: float, - has_todo_markers: bool = False, - has_notes_section: bool = False, - needs_splitting: bool = False, - splitting_suggestion: str | None = None, - ) -> None: - """ - Initialize refinement result. - - Args: - refined_body: AI-refined body content - confidence: Confidence score (0.0-1.0) - has_todo_markers: Whether refinement contains TODO markers - has_notes_section: Whether refinement contains NOTES section - needs_splitting: Whether story should be split (complexity detection) - splitting_suggestion: Suggestion for how to split the story - """ - self.refined_body = refined_body - self.confidence = confidence - self.has_todo_markers = has_todo_markers - self.has_notes_section = has_notes_section - self.needs_splitting = needs_splitting - self.splitting_suggestion = splitting_suggestion - - -class BacklogAIRefiner: - """ - Backlog refinement prompt generator and validator. - - This class generates prompts for IDE AI copilots to refine backlog items, - and validates/processes the refined content returned by the AI copilot. - - SpecFact CLI does NOT directly invoke LLM APIs. Instead: - 1. Generate prompt for IDE AI copilot - 2. IDE AI copilot executes prompt using its native LLM - 3. IDE AI copilot feeds refined content back to SpecFact CLI - 4. SpecFact CLI validates and processes the refined content - """ - - # Scrum threshold: stories > 13 points should be split - SCRUM_SPLIT_THRESHOLD = 13 - - @beartype - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @require(lambda self, template: isinstance(template, BacklogTemplate), "Template must be BacklogTemplate") - @require( - lambda self, comments=None: comments is None or isinstance(comments, list), - "Comments must be a list of strings or None", - ) - @ensure(lambda result: isinstance(result, str) and len(result) > 0, "Must return non-empty prompt string") - def generate_refinement_prompt( - self, item: BacklogItem, template: BacklogTemplate, comments: list[str] | None = None - ) -> str: - """ - Generate prompt for IDE AI copilot to refine backlog item. - - This prompt instructs the IDE AI copilot to: - 1. Transform the backlog item into target template format - 2. Preserve original intent and scope - 3. Return refined content for validation - - Args: - item: BacklogItem to refine - template: Target BacklogTemplate - - Returns: - Prompt string for IDE AI copilot - """ - effective_required_sections = get_effective_required_sections(item, template) - required_sections_str = "\n".join(f"- {section}" for section in effective_required_sections) or "- None" - optional_sections = list(template.optional_sections or []) - if item.provider.lower() == "ado": - ado_structured_optional_sections = {"Area Path", "Iteration Path"} - optional_sections = [ - section for section in optional_sections if section not in ado_structured_optional_sections - ] - optional_sections_str = ( - "\n".join(f"- {section}" for section in optional_sections) if optional_sections else "None" - ) - - # Provider-specific instructions - provider_instructions = "" - if item.provider == "github": - provider_instructions = """ -For GitHub issues: Use markdown headings (## Section Name) in the body to structure content. -Each required section should be a markdown heading with content below it.""" - elif item.provider == "ado": - provider_instructions = """ -For Azure DevOps work items: fields are structured and mapped separately. -- Keep metadata values (Story Points, Business Value, Priority, Work Item Type) in the metadata block, not as body headings. -- Keep the description body narrative clean (no duplicated metadata labels/headings inside description text). -- The adapter maps metadata and acceptance fields back to ADO structured fields during writeback.""" - - # Include story points, business value, priority if available - metrics_info = "" - if item.story_points is not None: - metrics_info += f"\nStory Points: {item.story_points}" - if item.business_value is not None: - metrics_info += f"\nBusiness Value: {item.business_value}" - if item.priority is not None: - metrics_info += f"\nPriority: {item.priority} (1=highest)" - if item.value_points is not None: - metrics_info += f"\nValue Points (SAFe): {item.value_points}" - if item.work_item_type: - metrics_info += f"\nWork Item Type: {item.work_item_type}" - - comment_lines: list[str] = [] - if comments: - comment_lines.append("Comments (latest discussion context):") - for index, comment in enumerate(comments, 1): - comment_lines.append(f"{index}. {comment}") - else: - comment_lines.append("Comments (latest discussion context):") - comment_lines.append("- No comments found") - comments_info = "\n".join(comment_lines) - - prompt = f"""Transform the following backlog item into the {template.name} template format. - -Original Backlog Item: -Title: {item.title} -Provider: {item.provider} -{metrics_info} - -Body: -{item.body_markdown} - -{comments_info} - -Target Template: {template.name} -Description: {template.description} - -Required Sections: -{required_sections_str} - -Optional Sections: -{optional_sections_str} -{provider_instructions} - -Instructions: -1. Preserve all original requirements, scope, and technical details -2. Do NOT add new features or change the scope -3. Do NOT summarize, shorten, or silently drop details from the original story content -4. Transform the content to match the template structure -5. If information is missing for a required section, use a Markdown checkbox: - [ ] describe what's needed -6. If you detect conflicting or ambiguous information, add a [NOTES] section at the end explaining the ambiguity -7. Use markdown formatting for sections (## Section Name) -8. Include story points, business value, priority, and work item type if available in the appropriate sections -9. For stories with high story points (>13 for Scrum, >21 for SAFe), consider suggesting story splitting -10. Provider-aware formatting: - - **GitHub**: Use markdown headings in body (## Section Name) - - **ADO**: Use markdown headings in body (will be mapped to separate ADO fields during writeback) -11. Omit unknown metadata fields instead of placeholders (do not emit values like "unspecified", "no info provided", or "provide area path") -12. Keep `## Description` focused on narrative body content; do not place metadata labels in description text. - -Expected Output Scaffold (ordered): -## Work Item Properties / Metadata -- Story Points: <number, omit line if unknown> -- Business Value: <number, omit line if unknown> -- Priority: <number, omit line if unknown> -- Work Item Type: <type, omit line if unknown> - -## Description -<main story narrative/body only> - -## Acceptance Criteria -- [ ] <criterion> - -## Notes -<optional; include only for ambiguity/risk/dependency context> - -Metadata scaffold rules: -- The `## Work Item Properties / Metadata` section is optional; include only when at least one metadata value is known. -- Omit unknown metadata fields and do not emit placeholders. - -Return ONLY the refined backlog item body content in markdown format. Do not include any explanations or metadata.""" - return prompt.strip() - - @beartype - @require( - lambda self, refined_body: isinstance(refined_body, str) and len(refined_body) > 0, - "Refined body must be non-empty", - ) - @require(lambda self, template: isinstance(template, BacklogTemplate), "Template must be BacklogTemplate") - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, bool), "Must return bool") - def _validate_required_sections(self, refined_body: str, template: BacklogTemplate, item: BacklogItem) -> bool: - """ - Validate that refined content contains all required sections. - - Note: Refined content is always markdown (from AI copilot), so we always check - markdown headings regardless of provider. The provider-aware logic is used for - extraction, but validation of refined content always uses markdown heading checks. - - Args: - refined_body: Refined body content (always markdown) - template: Target BacklogTemplate - item: BacklogItem being validated (used for context, not field checking) - - Returns: - True if all required sections are present, False otherwise - """ - if not template.required_sections: - return True # No requirements = valid - - effective_required_sections = get_effective_required_sections(item, template) - if not effective_required_sections: - return True - - # Refined content is always markdown (from AI copilot), so check markdown headings - body_lower = refined_body.lower() - for section in effective_required_sections: - section_lower = section.lower() - # Check for markdown heading - heading_pattern = rf"^#+\s+{re.escape(section_lower)}\s*$" - found = re.search(heading_pattern, body_lower, re.MULTILINE | re.IGNORECASE) - if not found and section_lower not in body_lower: - return False - return True - - @beartype - @require(lambda self, refined_body: isinstance(refined_body, str), "Refined body must be string") - @ensure(lambda result: isinstance(result, bool), "Must return bool") - def _has_todo_markers(self, refined_body: str) -> bool: - """ - Check if refined content contains TODO markers. - - Args: - refined_body: Refined body content - - Returns: - True if TODO markers are present, False otherwise - """ - # Use normalized uppercase text + case-sensitive regex to avoid regex engine - # edge-cases with IGNORECASE in symbolic/exploration contexts. - normalized_body = refined_body.upper() - todo_pattern = r"\[TODO[:\s][^\]]+\]" - return bool(re.search(todo_pattern, normalized_body)) - - @beartype - @require(lambda self, refined_body: isinstance(refined_body, str), "Refined body must be string") - @ensure(lambda result: isinstance(result, bool), "Must return bool") - def _has_notes_section(self, refined_body: str) -> bool: - """ - Check if refined content contains NOTES section. - - Args: - refined_body: Refined body content - - Returns: - True if NOTES section is present, False otherwise - """ - notes_pattern = r"^#+\s+NOTES\s*$" - return bool(re.search(notes_pattern, refined_body, re.MULTILINE | re.IGNORECASE)) - - @beartype - @require(lambda self, refined_body: isinstance(refined_body, str), "Refined body must be string") - @require(lambda self, original_body: isinstance(original_body, str), "Original body must be string") - @ensure(lambda result: isinstance(result, bool), "Must return bool") - def _has_significant_size_increase(self, refined_body: str, original_body: str) -> bool: - """ - Check if refined body has significant size increase (possible hallucination). - - Args: - refined_body: Refined body content - original_body: Original body content - - Returns: - True if size increased significantly (>50%), False otherwise - """ - if not original_body: - return False - size_increase = (len(refined_body) - len(original_body)) / len(original_body) - return size_increase > 0.5 - - @beartype - @require(lambda self, refined_body: isinstance(refined_body, str), "Refined body must be string") - @require(lambda self, original_body: isinstance(original_body, str), "Original body must be string") - @require(lambda self, template: isinstance(template, BacklogTemplate), "Template must be BacklogTemplate") - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, RefinementResult), "Must return RefinementResult") - def validate_and_score_refinement( - self, - refined_body: str, - original_body: str, - template: BacklogTemplate, - item: BacklogItem, - ) -> RefinementResult: - """ - Validate and score refined content from IDE AI copilot. - - This method validates the refined content returned by the IDE AI copilot - and calculates a confidence score based on completeness and quality. - - Args: - refined_body: Refined body content from IDE AI copilot - original_body: Original body content - template: Target BacklogTemplate - item: BacklogItem being validated (for provider-aware validation) - - Returns: - RefinementResult with validated content and confidence score - - Raises: - ValueError: If refined content is invalid or malformed - """ - if not refined_body.strip(): - msg = "Refined body is empty" - raise ValueError(msg) - - # Validate required sections (provider-aware) - if not self._validate_required_sections(refined_body, template, item): - msg = f"Refined content is missing required sections: {get_effective_required_sections(item, template)}" - raise ValueError(msg) - - # Validate story points, business value, priority fields if present - validation_errors = self._validate_agile_fields(item) - if validation_errors: - msg = f"Field validation errors: {', '.join(validation_errors)}" - raise ValueError(msg) - - # Check for TODO markers and NOTES section - has_todo = self._has_todo_markers(refined_body) - has_notes = self._has_notes_section(refined_body) - - # Detect story splitting needs - needs_splitting, splitting_suggestion = self._detect_story_splitting(item) - - # Calculate confidence - confidence = self._calculate_confidence(refined_body, original_body, template, item, has_todo, has_notes) - - return RefinementResult( - refined_body=refined_body, - confidence=confidence, - has_todo_markers=has_todo, - has_notes_section=has_notes, - needs_splitting=needs_splitting, - splitting_suggestion=splitting_suggestion, - ) - - @beartype - @require(lambda self, refined_body: isinstance(refined_body, str), "Refined body must be string") - @require(lambda self, original_body: isinstance(original_body, str), "Original body must be string") - @require(lambda self, template: isinstance(template, BacklogTemplate), "Template must be BacklogTemplate") - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, float) and 0.0 <= result <= 1.0, "Must return float in [0.0, 1.0]") - def _calculate_confidence( - self, - refined_body: str, - original_body: str, - template: BacklogTemplate, - item: BacklogItem, - has_todo: bool, - has_notes: bool, - ) -> float: - """ - Calculate confidence score for refined content. - - Args: - refined_body: Refined body content - original_body: Original body content - template: Target BacklogTemplate - item: BacklogItem being validated - has_todo: Whether TODO markers are present - has_notes: Whether NOTES section is present - - Returns: - Confidence score (0.0-1.0) - """ - # Base confidence: 1.0 if all required sections present, 0.8 otherwise - base_confidence = 1.0 if self._validate_required_sections(refined_body, template, item) else 0.8 - - # Bonus for having story points, business value, priority (indicates completeness) - if item.story_points is not None or item.business_value is not None or item.priority is not None: - base_confidence = min(1.0, base_confidence + 0.05) - - # Deduct 0.1 per TODO marker (max 2 TODO markers checked) - if has_todo: - todo_count = len(re.findall(r"\[TODO[:\s][^\]]+\]", refined_body, re.IGNORECASE)) - base_confidence -= min(0.1 * todo_count, 0.2) # Max deduction 0.2 - - # Deduct 0.15 for NOTES section - if has_notes: - base_confidence -= 0.15 - - # Deduct 0.1 for significant size increase (possible hallucination) - if self._has_significant_size_increase(refined_body, original_body): - base_confidence -= 0.1 - - # Ensure confidence is in [0.0, 1.0] - return max(0.0, min(1.0, base_confidence)) - - @beartype - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "Must return tuple (bool, str | None)") - def _detect_story_splitting(self, item: BacklogItem) -> tuple[bool, str | None]: - """ - Detect if story needs splitting based on complexity (Scrum/SAFe thresholds). - - Stories > 13 points (Scrum) or multi-sprint stories should be split into - multiple stories under the same feature. - - Args: - item: BacklogItem to check - - Returns: - Tuple of (needs_splitting: bool, suggestion: str | None) - """ - if item.story_points is None: - return (False, None) - - # Check if story exceeds Scrum threshold - if item.story_points > self.SCRUM_SPLIT_THRESHOLD: - suggestion = ( - f"Story has {item.story_points} story points, which exceeds the Scrum threshold of {self.SCRUM_SPLIT_THRESHOLD} points. " - f"Consider splitting into multiple smaller stories under the same feature. " - f"Each story should be 1-{self.SCRUM_SPLIT_THRESHOLD} points and completable within a single sprint." - ) - return (True, suggestion) - - # Check for multi-sprint stories (stories spanning multiple iterations) - # This is indicated by story points > typical sprint capacity (13 points) - # or by explicit iteration/sprint tracking showing multiple sprints - if item.sprint and item.iteration and item.story_points and item.story_points > self.SCRUM_SPLIT_THRESHOLD: - # If story has both sprint and iteration, check if it spans multiple sprints - # (This would require more context, but we can flag high-point stories) - suggestion = ( - f"Story may span multiple sprints ({item.story_points} points). " - f"Consider splitting into multiple stories to ensure each can be completed in a single sprint." - ) - return (True, suggestion) - - # SAFe-specific: Check Feature → Story hierarchy - if ( - item.work_item_type - and item.work_item_type.lower() in ["user story", "story"] - and item.story_points - and item.story_points > 21 - ): # Very high for a story - # In SAFe, stories should have a Feature parent - # If story_points is very high, it might be a Feature masquerading as a Story - suggestion = ( - f"Story has {item.story_points} points, which is unusually high for a User Story in SAFe. " - f"Consider if this should be a Feature instead, or split into multiple Stories under a Feature." - ) - return (True, suggestion) - - return (False, None) - - @beartype - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, list), "Must return list of strings") - def _validate_agile_fields(self, item: BacklogItem) -> list[str]: - """ - Validate agile framework fields (story_points, business_value, priority). - - Args: - item: BacklogItem to validate - - Returns: - List of validation error messages (empty if all valid) - """ - errors: list[str] = [] - - # Validate story_points (0-100 range, Scrum/SAFe) - if item.story_points is not None: - if not isinstance(item.story_points, int): - errors.append(f"story_points must be int, got {type(item.story_points).__name__}") - elif item.story_points < 0 or item.story_points > 100: - errors.append(f"story_points must be in range 0-100, got {item.story_points}") - - # Validate business_value (0-100 range, Scrum/SAFe) - if item.business_value is not None: - if not isinstance(item.business_value, int): - errors.append(f"business_value must be int, got {type(item.business_value).__name__}") - elif item.business_value < 0 or item.business_value > 100: - errors.append(f"business_value must be in range 0-100, got {item.business_value}") - - # Validate priority (1-4 range, 1=highest, all frameworks) - if item.priority is not None: - if not isinstance(item.priority, int): - errors.append(f"priority must be int, got {type(item.priority).__name__}") - elif item.priority < 1 or item.priority > 4: - errors.append(f"priority must be in range 1-4 (1=highest), got {item.priority}") - - # Validate value_points (SAFe-specific, should be calculated from business_value / story_points) - if item.value_points is not None: - if not isinstance(item.value_points, int): - errors.append(f"value_points must be int, got {type(item.value_points).__name__}") - elif item.value_points < 0: - errors.append(f"value_points must be non-negative, got {item.value_points}") - - return errors diff --git a/src/specfact_cli/backlog/format_detector.py b/src/specfact_cli/backlog/format_detector.py deleted file mode 100644 index 5561ab34..00000000 --- a/src/specfact_cli/backlog/format_detector.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Format detection for backlog content. - -This module provides heuristics to automatically detect the format of raw -backlog content (JSON, YAML, or Markdown). -""" - -from __future__ import annotations - -from beartype import beartype -from icontract import ensure, require - - -@beartype -@require(lambda raw: isinstance(raw, str), "Raw must be string") -@ensure(lambda result: result in ("json", "yaml", "markdown"), "Must return valid format type") -def detect_format(raw: str) -> str: - """ - Detect the format of raw backlog content. - - Uses heuristics: - - JSON: starts with "{" or "[" - - YAML: starts with "---" or contains ":" in first line - - Markdown: default for other cases - - Args: - raw: Raw content string - - Returns: - Format type ("json", "yaml", or "markdown") - """ - stripped = raw.strip() - - # Detect JSON (starts with { or [) - if stripped.startswith(("{", "[")): - return "json" - - # Detect YAML (starts with --- or has : in first line) - if stripped.startswith("---"): - return "yaml" - - first_line = stripped.split("\n")[0] if "\n" in stripped else stripped - if ":" in first_line and not first_line.startswith("#"): - # Check if it looks like YAML (key: value pattern) - parts = first_line.split(":", 1) - if len(parts) == 2 and parts[0].strip() and parts[1].strip(): - return "yaml" - - # Default to markdown - return "markdown" diff --git a/src/specfact_cli/backlog/formats/__init__.py b/src/specfact_cli/backlog/formats/__init__.py deleted file mode 100644 index 33232cea..00000000 --- a/src/specfact_cli/backlog/formats/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -Backlog format implementations. - -This module provides format serialization implementations for backlog items. -""" - -from __future__ import annotations - -from specfact_cli.backlog.formats.base import BacklogFormat -from specfact_cli.backlog.formats.markdown_format import MarkdownFormat -from specfact_cli.backlog.formats.structured_format import StructuredFormat - - -__all__ = ["BacklogFormat", "MarkdownFormat", "StructuredFormat"] diff --git a/src/specfact_cli/backlog/formats/base.py b/src/specfact_cli/backlog/formats/base.py deleted file mode 100644 index c09579ab..00000000 --- a/src/specfact_cli/backlog/formats/base.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Backlog format abstraction base class. - -This module defines the BacklogFormat interface for serializing and deserializing -backlog items across different formats (Markdown, YAML, JSON). -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.models.backlog_item import BacklogItem - - -class BacklogFormat(ABC): - """ - Abstract base class for backlog format serialization. - - This class provides a standard interface for converting BacklogItem instances - to and from different formats (Markdown, YAML, JSON). - """ - - @property - @abstractmethod - @beartype - @ensure(lambda result: isinstance(result, str) and len(result) > 0, "Must return non-empty format type") - def format_type(self) -> str: - """ - Get the format type identifier. - - Returns: - Format type (e.g., "markdown", "yaml", "json") - """ - ... - - @abstractmethod - @beartype - @require(lambda item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, str), "Must return string") - def serialize(self, item: BacklogItem) -> str: - """ - Serialize a BacklogItem to the format. - - Args: - item: BacklogItem to serialize - - Returns: - Serialized string representation - """ - ... - - @abstractmethod - @beartype - @require(lambda raw: isinstance(raw, str), "Raw must be string") - @ensure(lambda result: isinstance(result, BacklogItem), "Must return BacklogItem") - def deserialize(self, raw: str) -> BacklogItem: - """ - Deserialize a string to a BacklogItem. - - Args: - raw: Raw string representation - - Returns: - BacklogItem instance - """ - ... - - @beartype - @require(lambda item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, bool), "Must return boolean") - def roundtrip_preserves_content(self, item: BacklogItem) -> bool: - """ - Verify that serialization followed by deserialization preserves content. - - Args: - item: Original BacklogItem - - Returns: - True if round-trip preserves content, False otherwise - - Note: - This method has a default implementation but can be overridden - by formats that need custom validation logic. - """ - serialized = self.serialize(item) - deserialized = self.deserialize(serialized) - return ( - item.id == deserialized.id - and item.provider == deserialized.provider - and item.title == deserialized.title - and item.body_markdown == deserialized.body_markdown - and item.state == deserialized.state - and item.assignees == deserialized.assignees - and item.tags == deserialized.tags - ) diff --git a/src/specfact_cli/backlog/formats/markdown_format.py b/src/specfact_cli/backlog/formats/markdown_format.py deleted file mode 100644 index b47f3f1a..00000000 --- a/src/specfact_cli/backlog/formats/markdown_format.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Markdown format implementation for backlog items. - -This module provides Markdown serialization for BacklogItem instances, -supporting optional YAML frontmatter for metadata. -""" - -from __future__ import annotations - -import re -from typing import Any - -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.backlog.formats.base import BacklogFormat -from specfact_cli.models.backlog_item import BacklogItem - - -class MarkdownFormat(BacklogFormat): - """ - Markdown format serializer/deserializer for backlog items. - - Supports: - - Plain markdown (body_markdown only) - - Markdown with YAML frontmatter (for provider_fields metadata) - """ - - @property - @beartype - def format_type(self) -> str: - """Get format type identifier.""" - return "markdown" - - @beartype - @require(lambda item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, str), "Must return string") - def serialize(self, item: BacklogItem) -> str: - """ - Serialize BacklogItem to markdown. - - If provider_fields exist, includes YAML frontmatter. - - Args: - item: BacklogItem to serialize - - Returns: - Markdown string with optional YAML frontmatter - """ - lines: list[str] = [] - - # Add YAML frontmatter if provider_fields exist - if item.provider_fields: - lines.append("---") - # Convert provider_fields to YAML (simple key-value pairs) - for key, value in item.provider_fields.items(): - if isinstance(value, (str, int, float, bool)): - lines.append(f"{key}: {value}") - elif isinstance(value, dict): - # Simple dict representation - lines.append(f"{key}:") - for sub_key, sub_value in value.items(): - lines.append(f" {sub_key}: {sub_value}") - lines.append("---") - lines.append("") - - # Add body markdown - lines.append(item.body_markdown) - - return "\n".join(lines) - - @beartype - @require(lambda raw: isinstance(raw, str), "Raw must be string") - @ensure(lambda result: isinstance(result, BacklogItem), "Must return BacklogItem") - def deserialize(self, raw: str) -> BacklogItem: - """ - Deserialize markdown to BacklogItem. - - Parses YAML frontmatter if present, otherwise uses body as-is. - - Args: - raw: Markdown string with optional YAML frontmatter - - Returns: - BacklogItem instance - - Note: - This is a simplified implementation. For production use, consider - using a proper YAML parser for frontmatter extraction. - """ - provider_fields: dict[str, Any] = {} - body_markdown = raw - - # Check for YAML frontmatter - frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n(.*)$" - match = re.match(frontmatter_pattern, raw, re.DOTALL) - - if match: - frontmatter_text = match.group(1) - body_markdown = match.group(2) - - # Parse simple YAML frontmatter (key: value pairs) - for line in frontmatter_text.split("\n"): - line = line.strip() - if ":" in line: - key, value = line.split(":", 1) - key = key.strip() - value = value.strip() - # Try to parse value as appropriate type - if value.lower() == "true": - provider_fields[key] = True - elif value.lower() == "false": - provider_fields[key] = False - elif value.isdigit(): - provider_fields[key] = int(value) - else: - provider_fields[key] = value - - # Create minimal BacklogItem (requires id, provider, url, title, state) - # This is a simplified implementation - in practice, you'd need more context - # For now, we'll create a placeholder item - return BacklogItem( - id="placeholder", - provider="unknown", - url="", - title="", - body_markdown=body_markdown, - state="open", - provider_fields=provider_fields if provider_fields else {}, - ) diff --git a/src/specfact_cli/backlog/formats/structured_format.py b/src/specfact_cli/backlog/formats/structured_format.py deleted file mode 100644 index 7a317a2c..00000000 --- a/src/specfact_cli/backlog/formats/structured_format.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Structured format implementation (YAML/JSON) for backlog items. - -This module provides YAML and JSON serialization for BacklogItem instances. -""" - -from __future__ import annotations - -import json - -from beartype import beartype -from icontract import ensure, require -from ruamel.yaml import YAML - -from specfact_cli.backlog.formats.base import BacklogFormat -from specfact_cli.models.backlog_item import BacklogItem - - -class StructuredFormat(BacklogFormat): - """ - Structured format serializer/deserializer (YAML/JSON) for backlog items. - - Supports both YAML and JSON format types. - """ - - def __init__(self, format_type: str = "yaml") -> None: - """ - Initialize structured format. - - Args: - format_type: Format type ("yaml" or "json") - """ - if format_type not in ("yaml", "json"): - msg = f"Format type must be 'yaml' or 'json', got: {format_type}" - raise ValueError(msg) - self._format_type = format_type - self._yaml = YAML() if format_type == "yaml" else None - - @property - @beartype - def format_type(self) -> str: - """Get format type identifier.""" - return self._format_type - - @beartype - @require(lambda item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @ensure(lambda result: isinstance(result, str), "Must return string") - def serialize(self, item: BacklogItem) -> str: - """ - Serialize BacklogItem to YAML or JSON. - - Args: - item: BacklogItem to serialize - - Returns: - YAML or JSON string representation - """ - # Convert BacklogItem to dict - data = item.model_dump() - - if self._format_type == "yaml": - # Serialize to YAML - from io import StringIO - - if self._yaml is None: - msg = "YAML instance not initialized" - raise ValueError(msg) - stream = StringIO() - self._yaml.dump(data, stream) - return stream.getvalue() - # Serialize to JSON - return json.dumps(data, indent=2, default=str) - - @beartype - @require(lambda raw: isinstance(raw, str), "Raw must be string") - @ensure(lambda result: isinstance(result, BacklogItem), "Must return BacklogItem") - def deserialize(self, raw: str) -> BacklogItem: - """ - Deserialize YAML or JSON to BacklogItem. - - Args: - raw: YAML or JSON string - - Returns: - BacklogItem instance - """ - # Deserialize from YAML or JSON - if self._format_type == "yaml": - if self._yaml is None: - msg = "YAML instance not initialized" - raise ValueError(msg) - data = self._yaml.load(raw) - else: - data = json.loads(raw) - - # Create BacklogItem from dict - return BacklogItem(**data) diff --git a/src/specfact_cli/backlog/template_detector.py b/src/specfact_cli/backlog/template_detector.py deleted file mode 100644 index 3a4820cc..00000000 --- a/src/specfact_cli/backlog/template_detector.py +++ /dev/null @@ -1,288 +0,0 @@ -""" -Template detection engine for backlog items. - -This module provides structural and pattern-based template matching with -confidence scoring (60% structure, 40% pattern). -""" - -from __future__ import annotations - -import re - -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.models.backlog_item import BacklogItem -from specfact_cli.templates.registry import BacklogTemplate, TemplateRegistry - - -@beartype -@require(lambda item: isinstance(item, BacklogItem), "Item must be BacklogItem") -@require(lambda template: isinstance(template, BacklogTemplate), "Template must be BacklogTemplate") -@ensure(lambda result: isinstance(result, list), "Must return list") -def get_effective_required_sections(item: BacklogItem, template: BacklogTemplate) -> list[str]: - """ - Return required sections that should be validated in body content for this provider. - - For ADO, structured fields are stored outside the description body and should not be - enforced as markdown body sections. - """ - required_sections = list(template.required_sections or []) - if item.provider.lower() != "ado": - return required_sections - - ado_structured_sections = { - "Story Points", - "Business Value", - "Priority", - "Work Item Type", - "Value Points", - "Area Path", - "Iteration Path", - } - return [section for section in required_sections if section not in ado_structured_sections] - - -class TemplateDetectionResult: - """Result of template detection with confidence and missing fields.""" - - def __init__( - self, - template_id: str | None = None, - confidence: float = 0.0, - missing_fields: list[str] | None = None, - ) -> None: - """ - Initialize template detection result. - - Args: - template_id: Detected template ID (None if no match) - confidence: Confidence score (0.0-1.0) - missing_fields: List of missing required fields - """ - self.template_id = template_id - self.confidence = confidence - self.missing_fields = missing_fields or [] - - -class TemplateDetector: - """ - Template detection engine with structural and pattern-based matching. - - The detector uses: - - Structural fit scoring (60% weight): Checks presence of required section headings - - Pattern fit scoring (40% weight): Matches title and body regex patterns - - Weighted confidence calculation: 0.6 * structural_score + 0.4 * pattern_score - """ - - def __init__(self, registry: TemplateRegistry) -> None: - """ - Initialize template detector. - - Args: - registry: TemplateRegistry instance - """ - self.registry = registry - - @beartype - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @require(lambda self, template: isinstance(template, BacklogTemplate), "Template must be BacklogTemplate") - @ensure(lambda result: isinstance(result, float) and 0.0 <= result <= 1.0, "Must return float in [0.0, 1.0]") - def _score_structural_fit(self, item: BacklogItem, template: BacklogTemplate) -> float: - """ - Score structural fit by checking required section headings. - - Args: - item: BacklogItem to check - template: BacklogTemplate to match against - - Returns: - Structural fit score (0.0-1.0) - """ - required_sections = get_effective_required_sections(item, template) - if not required_sections: - return 1.0 # No requirements = perfect match - - body_lower = item.body_markdown.lower() - found_sections = 0 - - for section in required_sections: - # Check for exact heading match (markdown heading) - section_lower = section.lower() - # Match markdown headings: # Section, ## Section, ### Section, etc. - heading_pattern = rf"^#+\s+{re.escape(section_lower)}\s*$" - if re.search(heading_pattern, body_lower, re.MULTILINE | re.IGNORECASE): - found_sections += 1 - continue - - # Check for fuzzy match (section appears in text) - if section_lower in body_lower: - found_sections += 1 - - if not required_sections: - return 1.0 - - return found_sections / len(required_sections) - - @beartype - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @require(lambda self, template: isinstance(template, BacklogTemplate), "Template must be BacklogTemplate") - @ensure(lambda result: isinstance(result, float) and 0.0 <= result <= 1.0, "Must return float in [0.0, 1.0]") - def _score_pattern_fit(self, item: BacklogItem, template: BacklogTemplate) -> float: - """ - Score pattern fit by matching regex patterns. - - Args: - item: BacklogItem to check - template: BacklogTemplate to match against - - Returns: - Pattern fit score (0.0-1.0) - """ - patterns_to_check: list[tuple[str, str]] = [] - - # Add title patterns - for pattern in template.title_patterns: - patterns_to_check.append(("title", pattern)) - - # Add body patterns - for _pattern_name, pattern in template.body_patterns.items(): - patterns_to_check.append(("body", pattern)) - - if not patterns_to_check: - return 1.0 # No patterns = perfect match - - matched_patterns = 0 - for pattern_type, pattern in patterns_to_check: - text = item.title if pattern_type == "title" else item.body_markdown - try: - if re.search(pattern, text, re.IGNORECASE | re.MULTILINE): - matched_patterns += 1 - except re.error: - # Invalid regex pattern, skip - continue - - return matched_patterns / len(patterns_to_check) if patterns_to_check else 1.0 - - @beartype - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @require(lambda self, template: isinstance(template, BacklogTemplate), "Template must be BacklogTemplate") - @ensure(lambda result: isinstance(result, list), "Must return list of strings") - def _find_missing_fields(self, item: BacklogItem, template: BacklogTemplate) -> list[str]: - """ - Find missing required fields for a template. - - Args: - item: BacklogItem to check - template: BacklogTemplate to match against - - Returns: - List of missing required section names - """ - missing: list[str] = [] - body_lower = item.body_markdown.lower() - - for section in get_effective_required_sections(item, template): - section_lower = section.lower() - # Check for exact heading match - heading_pattern = rf"^#+\s+{re.escape(section_lower)}\s*$" - found = re.search(heading_pattern, body_lower, re.MULTILINE | re.IGNORECASE) - if not found and section_lower not in body_lower: - missing.append(section) - - return missing - - @beartype - @require(lambda self, item: isinstance(item, BacklogItem), "Item must be BacklogItem") - @require(lambda self, provider: provider is None or isinstance(provider, str), "Provider must be str or None") - @require(lambda self, framework: framework is None or isinstance(framework, str), "Framework must be str or None") - @require(lambda self, persona: persona is None or isinstance(persona, str), "Persona must be str or None") - @ensure(lambda result: isinstance(result, TemplateDetectionResult), "Must return TemplateDetectionResult") - def detect_template( - self, - item: BacklogItem, - provider: str | None = None, - framework: str | None = None, - persona: str | None = None, - ) -> TemplateDetectionResult: - """ - Detect which template (if any) a backlog item matches. - - Uses priority-based template resolution with persona/framework/provider filtering. - - Args: - item: BacklogItem to analyze - provider: Provider name for template filtering (default: from item.provider) - framework: Framework name for template filtering - persona: Persona name for template filtering - - Returns: - TemplateDetectionResult with template_id, confidence, and missing_fields - """ - # Use item.provider if provider not specified - if provider is None: - provider = item.provider - - # First, try to resolve template using priority-based resolution - resolved_template = self.registry.resolve_template(provider=provider, framework=framework, persona=persona) - - # If resolved template found, check if it matches the item - if resolved_template: - structural_score = self._score_structural_fit(item, resolved_template) - pattern_score = self._score_pattern_fit(item, resolved_template) - confidence = 0.6 * structural_score + 0.4 * pattern_score - - if confidence >= 0.5: - missing_fields = self._find_missing_fields(item, resolved_template) - return TemplateDetectionResult( - template_id=resolved_template.template_id, - confidence=confidence, - missing_fields=missing_fields, - ) - - # Fallback: Check all templates and find best match - best_match: TemplateDetectionResult | None = None - best_confidence = 0.0 - - # Get all corporate templates, filtered by provider/framework/persona if specified - templates = self.registry.list_templates(scope="corporate") - filtered_templates = templates - - # Apply filters - if provider: - filtered_templates = [t for t in filtered_templates if t.provider is None or t.provider == provider] - if framework: - filtered_templates = [t for t in filtered_templates if t.framework is None or t.framework == framework] - if persona: - filtered_templates = [t for t in filtered_templates if not t.personas or persona in t.personas] - - # If no templates match filters, use all templates - if not filtered_templates: - filtered_templates = templates - - for template in filtered_templates: - # Calculate structural fit (60% weight) - structural_score = self._score_structural_fit(item, template) - - # Calculate pattern fit (40% weight) - pattern_score = self._score_pattern_fit(item, template) - - # Weighted confidence: 0.6 * structural_score + 0.4 * pattern_score - confidence = 0.6 * structural_score + 0.4 * pattern_score - - # Track best match - if confidence > best_confidence: - best_confidence = confidence - missing_fields = self._find_missing_fields(item, template) - best_match = TemplateDetectionResult( - template_id=template.template_id, - confidence=confidence, - missing_fields=missing_fields, - ) - - # Return best match or no match result - if best_match and best_confidence >= 0.5: - return best_match - - # No match or low confidence - return TemplateDetectionResult(template_id=None, confidence=best_confidence, missing_fields=[]) diff --git a/src/specfact_cli/commands/backlog_commands.py b/src/specfact_cli/commands/backlog_commands.py deleted file mode 100644 index 2bee2c7f..00000000 --- a/src/specfact_cli/commands/backlog_commands.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Backward-compatible app shim for backlog command.""" - -from typing import TYPE_CHECKING, Any - -from ._bundle_shim import load_bundle_app - - -if TYPE_CHECKING: - app: Any - - -def __getattr__(name: str) -> Any: - if name == "app": - return load_bundle_app(__file__, "specfact_backlog.backlog.commands") - raise AttributeError(name) - - -__all__ = ["app"] diff --git a/src/specfact_cli/groups/__init__.py b/src/specfact_cli/groups/__init__.py index 10efd395..f9571f56 100644 --- a/src/specfact_cli/groups/__init__.py +++ b/src/specfact_cli/groups/__init__.py @@ -1,8 +1,7 @@ -"""Category group commands: project, backlog, code, spec, govern.""" +"""Category group commands: project, code, spec, govern.""" from __future__ import annotations -from specfact_cli.groups.backlog_group import app as backlog_app from specfact_cli.groups.codebase_group import app as codebase_app from specfact_cli.groups.govern_group import app as govern_app from specfact_cli.groups.project_group import app as project_app @@ -10,7 +9,6 @@ __all__ = [ - "backlog_app", "codebase_app", "govern_app", "project_app", diff --git a/src/specfact_cli/groups/backlog_group.py b/src/specfact_cli/groups/backlog_group.py deleted file mode 100644 index dff42cd2..00000000 --- a/src/specfact_cli/groups/backlog_group.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Backlog category group (backlog, policy). - -CrossHair: skip (Typer app wiring and lazy registry lookups are side-effectful by design) -""" - -from __future__ import annotations - -import typer -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.common import get_bridge_logger -from specfact_cli.registry.registry import CommandRegistry - - -_MEMBERS = [ - ("backlog", "backlog"), - ("policy", "policy"), -] - - -@require(lambda app: app is not None) -@ensure(lambda result: result is None) -@beartype -def _register_members(app: typer.Typer) -> None: - """Register member module sub-apps (called when group is first used).""" - logger = get_bridge_logger(__name__) - added = 0 - for display_name, cmd_name in _MEMBERS: - try: - member_app = CommandRegistry.get_module_typer(cmd_name) - if member_app is not None: - app.add_typer(member_app, name=display_name) - added += 1 - except ValueError as exc: - logger.debug("Backlog group: skipping %s (%s)", cmd_name, exc) - except Exception as exc: - logger.debug("Backlog group: failed to load %s: %s", cmd_name, exc) - if added == 0: - placeholder = typer.Typer(help="Backlog and policy commands (module not loaded).") - - @placeholder.command("install") - def _install_hint() -> None: - from specfact_cli.utils.prompts import print_warning - - print_warning("No backlog module loaded. Install with: specfact module install nold-ai/specfact-backlog") - - app.add_typer(placeholder, name="backlog") - - -def build_app() -> typer.Typer: - """Build the backlog group Typer with members (lazy; registry must be populated).""" - app = typer.Typer( - name="backlog", - help="Backlog and policy commands.", - no_args_is_help=True, - ) - _register_members(app) - app._specfact_flatten_same_name = "backlog" - return app - - -app = build_app() diff --git a/src/specfact_cli/groups/member_group.py b/src/specfact_cli/groups/member_group.py new file mode 100644 index 00000000..67f7ebc6 --- /dev/null +++ b/src/specfact_cli/groups/member_group.py @@ -0,0 +1,60 @@ +"""Generic category group builder for bundle-owned command surfaces.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.registry.registry import CommandRegistry + + +@require(lambda app: app is not None) +@beartype +def _register_members(app: typer.Typer, members: Sequence[tuple[str, str]]) -> int: + """Register member module sub-apps and return how many were added.""" + added = 0 + for display_name, cmd_name in members: + try: + member_app = CommandRegistry.get_module_typer(cmd_name) + if member_app is not None: + app.add_typer(member_app, name=display_name) + added += 1 + except ValueError: + continue + return added + + +@require(lambda name: isinstance(name, str) and len(name) > 0) +@require(lambda help_text: isinstance(help_text, str) and len(help_text) > 0) +@ensure(lambda result: isinstance(result, typer.Typer)) +@beartype +def build_member_group( + *, + name: str, + help_text: str, + members: Sequence[tuple[str, str]], + flatten_same_name: str | None = None, + install_hint_module: str | None = None, +) -> typer.Typer: + """Build a lazy category group from registered member modules.""" + app = typer.Typer(name=name, help=help_text, no_args_is_help=True) + added = _register_members(app, members) + + if added == 0 and install_hint_module: + placeholder = typer.Typer(help=f"{help_text} (module not loaded).") + + @placeholder.command("install") + def _install_hint() -> None: + from specfact_cli.utils.prompts import print_warning + + print_warning(f"No {name} module loaded. Install with: specfact module install {install_hint_module}") + + app.add_typer(placeholder, name=name) + + if flatten_same_name: + app._specfact_flatten_same_name = flatten_same_name + + return app diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index 58f77275..b6a2ed48 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -517,29 +517,6 @@ def _command_info_name(command_info: Any) -> str: return callback_name.replace("_", "-") if callback_name else "" -@beartype -def _is_expected_duplicate_extension(owner_module: str, command_name: str, subcommand_name: str) -> bool: - """Return True when duplicate command overlap is an expected core+bundle composition case.""" - if owner_module != "nold-ai/specfact-backlog": - return False - allowed_duplicates = { - ("backlog", "daily"), - ("backlog", "refine"), - ("backlog", "init-config"), - ("backlog", "map-fields"), - ("backlog ceremony", "standup"), - ("backlog ceremony", "refinement"), - ("backlog ceremony", "planning"), - ("backlog ceremony", "flow"), - ("backlog ceremony", "pi-summary"), - ("backlog auth", "azure-devops"), - ("backlog auth", "github"), - ("backlog auth", "status"), - ("backlog auth", "clear"), - } - return (command_name, subcommand_name) in allowed_duplicates - - @beartype def _merge_typer_apps(base_app: Any, extension_app: Any, owner_module: str, command_name: str) -> None: """Merge extension Typer commands/groups into an existing root Typer app.""" @@ -560,12 +537,7 @@ def _merge_typer_apps(base_app: Any, extension_app: Any, owner_module: str, comm if not subcommand_name: continue if subcommand_name in existing_command_names: - log_fn = ( - logger.debug - if _is_expected_duplicate_extension(owner_module, command_name, subcommand_name) - else logger.warning - ) - log_fn( + logger.warning( "Module %s attempted to extend command '%s' with duplicate subcommand '%s'; skipping duplicate.", owner_module, command_name, @@ -907,14 +879,24 @@ def _resolved_bundle(meta: Any) -> str | None: # Bundle name -> (group_name, help_str, build_app_fn) for conditional category mounting. def _build_bundle_to_group() -> dict[str, tuple[str, str, Any]]: - from specfact_cli.groups.backlog_group import build_app as build_backlog_app from specfact_cli.groups.codebase_group import build_app as build_codebase_app from specfact_cli.groups.govern_group import build_app as build_govern_app + from specfact_cli.groups.member_group import build_member_group from specfact_cli.groups.project_group import build_app as build_project_app from specfact_cli.groups.spec_group import build_app as build_spec_app return { - "specfact-backlog": ("backlog", "Backlog and policy commands.", build_backlog_app), + "specfact-backlog": ( + "backlog", + "Backlog and policy commands.", + lambda: build_member_group( + name="backlog", + help_text="Backlog and policy commands.", + members=(("backlog", "backlog"), ("policy", "policy")), + flatten_same_name="backlog", + install_hint_module="nold-ai/specfact-backlog", + ), + ), "specfact-codebase": ( "code", "Codebase quality commands: analyze, drift, validate, repro.", diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 77554920..a0fb9019 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -121,10 +121,6 @@ "specfact.06-sync", "specfact.07-contracts", "specfact.compare", - "specfact.sync-backlog", - "specfact.backlog-daily", - "specfact.backlog-refine", - "specfact.backlog-add", "specfact.validate", ] diff --git a/tests/e2e/backlog/test_backlog_refine_limit_and_cancel.py b/tests/e2e/backlog/test_backlog_refine_limit_and_cancel.py deleted file mode 100644 index 9e54bd2a..00000000 --- a/tests/e2e/backlog/test_backlog_refine_limit_and_cancel.py +++ /dev/null @@ -1,214 +0,0 @@ -""" -E2E tests for backlog refine --limit and cancel flow. - -Tests the complete workflow with batch limits and graceful cancellation. -""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest -from beartype import beartype - - -pytest.importorskip("specfact_backlog.backlog.commands") -from specfact_backlog.backlog.commands import _fetch_backlog_items - -from specfact_cli.backlog.filters import BacklogFilters -from specfact_cli.models.backlog_item import BacklogItem - - -class TestBacklogRefineLimitAndCancel: - """E2E tests for --limit and cancel flow.""" - - @beartype - def test_fetch_backlog_items_respects_limit(self) -> None: - """Test that _fetch_backlog_items respects the limit parameter.""" - # Create mock items - items = [ - BacklogItem( - id=str(i), - provider="github", - url=f"https://github.com/test/repo/issues/{i}", - title=f"Issue {i}", - body_markdown=f"Body {i}", - state="open", - ) - for i in range(1, 21) # 20 items - ] - - # Mock adapter to return all items - with patch("specfact_backlog.backlog.commands.AdapterRegistry") as mock_registry: - from specfact_cli.backlog.adapters.base import BacklogAdapter - - mock_adapter = MagicMock(spec=BacklogAdapter) - mock_adapter.fetch_backlog_items.return_value = items - mock_registry.return_value.get_adapter.return_value = mock_adapter - - # Fetch with limit - result = _fetch_backlog_items("github", limit=5, repo_owner="test", repo_name="repo") - - # Verify limit was applied - assert len(result) == 5 - assert all(item.id in ["1", "2", "3", "4", "5"] for item in result) - - @beartype - def test_fetch_backlog_items_no_limit_returns_all(self) -> None: - """Test that _fetch_backlog_items returns all items when limit is None.""" - items = [ - BacklogItem( - id=str(i), - provider="github", - url=f"https://github.com/test/repo/issues/{i}", - title=f"Issue {i}", - body_markdown=f"Body {i}", - state="open", - ) - for i in range(1, 11) # 10 items - ] - - with patch("specfact_backlog.backlog.commands.AdapterRegistry") as mock_registry: - from specfact_cli.backlog.adapters.base import BacklogAdapter - - mock_adapter = MagicMock(spec=BacklogAdapter) - mock_adapter.fetch_backlog_items.return_value = items - mock_registry.return_value.get_adapter.return_value = mock_adapter - - # Fetch without limit - result = _fetch_backlog_items("github", limit=None, repo_owner="test", repo_name="repo") - - # Verify all items returned - assert len(result) == 10 - - @beartype - def test_backlog_filters_limit_field(self) -> None: - """Test that BacklogFilters supports limit field.""" - filters = BacklogFilters(state="open", limit=10) - - assert filters.limit == 10 - assert filters.state == "open" - - # Verify limit is included in to_dict when set - filters_dict = filters.to_dict() - assert "limit" in filters_dict - assert filters_dict["limit"] == 10 - - # Verify limit is not in to_dict when None - filters_no_limit = BacklogFilters(state="open", limit=None) - filters_dict_no_limit = filters_no_limit.to_dict() - assert "limit" not in filters_dict_no_limit or filters_dict_no_limit.get("limit") is None - - @beartype - def test_ado_adapter_applies_limit_after_filtering(self) -> None: - """Test that ADO adapter applies limit after filtering.""" - from specfact_cli.adapters.ado import AdoAdapter - - with ( - patch("specfact_cli.adapters.ado.requests.post") as mock_post, - patch("specfact_cli.adapters.ado.requests.get") as mock_get, - ): - # Mock WIQL query - mock_post_response = MagicMock() - mock_post_response.json.return_value = {"workItems": [{"id": i} for i in range(1, 21)]} - mock_post_response.raise_for_status = MagicMock() - mock_post.return_value = mock_post_response - - # Mock work items fetch - mock_get_response = MagicMock() - mock_get_response.json.return_value = { - "value": [ - { - "id": i, - "url": f"https://dev.azure.com/test/proj/_apis/wit/workitems/{i}", - "fields": { - "System.Title": f"Item {i}", - "System.Description": f"Body {i}", - "System.State": "Active" if i % 2 == 0 else "New", - }, - } - for i in range(1, 21) - ] - } - mock_get_response.raise_for_status = MagicMock() - mock_get.return_value = mock_get_response - - adapter = AdoAdapter(org="test", project="proj", api_token="token") - filters = BacklogFilters(state="Active", limit=5) - - result = adapter.fetch_backlog_items(filters) - - # Verify limit was applied after filtering - # Should have 5 items (half are Active, limit is 5) - assert len(result) == 5 - # State is normalized to lowercase by converter - assert all(item.state.lower() == "active" for item in result) - - @beartype - def test_github_adapter_applies_limit_after_filtering(self) -> None: - """Test that GitHub adapter applies limit after filtering.""" - from specfact_cli.adapters.github import GitHubAdapter - - with patch("specfact_cli.adapters.github.requests.get") as mock_get: - # Mock search API response - mock_response = MagicMock() - mock_response.json.return_value = { - "items": [ - { - "number": i, - "html_url": f"https://github.com/test/repo/issues/{i}", - "title": f"Issue {i}", - "body": f"Body {i}", - "state": "open" if i % 2 == 0 else "closed", - "assignees": [], - "labels": [], - } - for i in range(1, 21) - ], - "total_count": 20, - } - mock_response.raise_for_status = MagicMock() - mock_get.return_value = mock_response - - adapter = GitHubAdapter(repo_owner="test", repo_name="repo", api_token="token") - filters = BacklogFilters(state="open", limit=5) - - result = adapter.fetch_backlog_items(filters) - - # Verify limit was applied after filtering - assert len(result) == 5 - assert all(item.state.lower() == "open" for item in result) - - @beartype - def test_cancel_flow_does_not_write_updates(self) -> None: - """Test that cancel flow (:quit/:abort) does not write updates.""" - # This is more of a behavioral test - in actual CLI flow, cancellation - # happens during interactive input, so we test the logic that prevents writes - - # Simulate cancelled session - cancelled = True - refined_count = 0 - - # Verify that when cancelled, no writes should occur - assert cancelled is True - assert refined_count == 0 # No items were refined before cancellation - - # In real flow, the cancellation flag prevents the write loop from executing - # This is tested implicitly through the code structure - - @beartype - def test_skip_flow_skips_current_item(self) -> None: - """Test that :skip command skips current item without updating.""" - # Simulate skip flow - skipped_count = 0 - refined_count = 0 - - # Simulate processing an item and skipping it - item_skipped = True - - if item_skipped: - skipped_count += 1 - - # Verify skip behavior - assert skipped_count == 1 - assert refined_count == 0 # Item was skipped, not refined diff --git a/tests/e2e/backlog/test_backlog_refinement_e2e.py b/tests/e2e/backlog/test_backlog_refinement_e2e.py deleted file mode 100644 index 1aee5c8c..00000000 --- a/tests/e2e/backlog/test_backlog_refinement_e2e.py +++ /dev/null @@ -1,364 +0,0 @@ -""" -End-to-end tests for backlog refinement. - -Tests the complete workflow from arbitrary DevOps backlog input to refined structured format. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from beartype import beartype - -from specfact_cli.backlog.ai_refiner import BacklogAIRefiner -from specfact_cli.backlog.converter import convert_ado_work_item_to_backlog_item, convert_github_issue_to_backlog_item -from specfact_cli.backlog.template_detector import TemplateDetector -from specfact_cli.templates.registry import TemplateRegistry - - -@pytest.fixture -def full_template_registry(tmp_path: Path) -> TemplateRegistry: - """Create template registry with all default templates.""" - registry = TemplateRegistry() - - defaults_dir = tmp_path / "templates" / "defaults" - defaults_dir.mkdir(parents=True) - - # User story template - (defaults_dir / "user_story_v1.yaml").write_text("""template_id: user_story_v1 -name: User Story -scope: corporate -required_sections: - - As a - - I want - - So that - - Acceptance Criteria -body_patterns: - as_a: "As a [^,]+ I want" -title_patterns: - - "^.*[Uu]ser [Ss]tory.*$" -""") - - # Defect template - (defaults_dir / "defect_v1.yaml").write_text("""template_id: defect_v1 -name: Defect -scope: corporate -required_sections: - - Description - - Steps to Reproduce - - Expected Behavior - - Actual Behavior -body_patterns: - steps: "[Ss]teps? to [Rr]eproduce" -title_patterns: - - "^.*[Bb]ug.*$" -""") - - registry.load_templates_from_directory(defaults_dir) - return registry - - -class TestBacklogRefinementE2E: - """End-to-end tests for backlog refinement.""" - - @beartype - def test_e2e_github_issue_to_user_story(self, full_template_registry: TemplateRegistry) -> None: - """E2E: Convert arbitrary GitHub issue → detect → refine → apply.""" - # Simulate arbitrary DevOps team input - arbitrary_github_issue = { - "number": 100, - "html_url": "https://github.com/org/repo/issues/100", - "title": "We need a way for users to authenticate", - "body": """Hi team, -Our users are asking for authentication. Currently they can't log in. -Can we add this feature? It's been requested multiple times. - -Let me know if you need more details. - -Thanks!""", - "state": "open", - "assignees": [{"login": "dev1"}], - "labels": [{"name": "feature"}, {"name": "priority"}], - "created_at": "2024-01-18T10:00:00Z", - "updated_at": "2024-01-19T14:30:00Z", - } - - # Step 1: Convert to BacklogItem - backlog_item = convert_github_issue_to_backlog_item(arbitrary_github_issue) - - assert backlog_item.id == "100" - assert backlog_item.provider == "github" - assert "authenticate" in backlog_item.title.lower() or "authentication" in backlog_item.title.lower() - - # Step 2: Detect template - detector = TemplateDetector(full_template_registry) - detection_result = detector.detect_template(backlog_item) - - # Arbitrary input should have low confidence - assert detection_result.confidence < 0.6 - assert backlog_item.needs_refinement is True - - # Step 3: Generate refinement prompt - refiner = BacklogAIRefiner() - template = full_template_registry.get_template("user_story_v1") - assert template is not None - - prompt = refiner.generate_refinement_prompt(backlog_item, template) - - # Verify prompt contains necessary information - assert backlog_item.title in prompt - assert backlog_item.body_markdown in prompt - assert template.name in prompt - - # Step 4: Simulate IDE AI copilot refinement - # (In real scenario, IDE AI copilot would execute the prompt) - refined_content = """## As a -registered user - -## I want -to authenticate and log in to the system - -## So that -I can access my account and protected resources - -## Acceptance Criteria -- User can enter username and password -- User can click login button -- System validates credentials against database -- User is redirected to dashboard on successful login -- Error message is shown on invalid credentials""" - - # Step 5: Validate refined content - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - - assert validation_result.confidence >= 0.85 - assert validation_result.has_todo_markers is False - assert validation_result.has_notes_section is False - - # Step 6: Apply refinement - backlog_item.refined_body = validation_result.refined_body - backlog_item.detected_template = template.template_id - backlog_item.template_confidence = validation_result.confidence - backlog_item.apply_refinement() - - # Verify final state - assert backlog_item.body_markdown == refined_content - assert backlog_item.refinement_applied is True - assert backlog_item.detected_template == "user_story_v1" - assert backlog_item.template_confidence is not None and backlog_item.template_confidence >= 0.85 - - @beartype - def test_e2e_ado_work_item_to_defect(self, full_template_registry: TemplateRegistry) -> None: - """E2E: Convert arbitrary ADO work item → detect → refine → apply.""" - # Simulate arbitrary DevOps team input - arbitrary_ado_item = { - "id": 200, - "url": "https://dev.azure.com/org/proj/_apis/wit/workitems/200", - "fields": { - "System.Title": "Something is broken", - "System.Description": """Users are reporting that the login page crashes. -It happens when they click the login button. -We need to fix this ASAP!""", - "System.State": "Active", - "System.WorkItemType": "Bug", - "System.Tags": "bug;critical;production", - "System.AssignedTo": {"displayName": "Dev Team", "uniqueName": "dev@example.com"}, - "System.IterationPath": "Sprint 2", - "System.AreaPath": "Frontend", - }, - } - - # Step 1: Convert to BacklogItem - backlog_item = convert_ado_work_item_to_backlog_item(arbitrary_ado_item) - - assert backlog_item.id == "200" - assert backlog_item.provider == "ado" - assert "broken" in backlog_item.title.lower() - - # Step 2: Detect template - detector = TemplateDetector(full_template_registry) - detection_result = detector.detect_template(backlog_item) - - # Arbitrary input should have low confidence - assert detection_result.confidence < 0.6 - - # Step 3: Generate refinement prompt for defect template - refiner = BacklogAIRefiner() - template = full_template_registry.get_template("defect_v1") - assert template is not None - - prompt = refiner.generate_refinement_prompt(backlog_item, template) - - assert backlog_item.title in prompt - assert "broken" in prompt.lower() - - # Step 4: Simulate IDE AI copilot refinement - refined_content = """## Description -The login page crashes when users click the login button. - -## Steps to Reproduce -1. Navigate to the login page -2. Enter any credentials -3. Click the login button -4. Page crashes with error - -## Expected Behavior -User should be logged in and redirected to dashboard. - -## Actual Behavior -Page crashes with JavaScript error.""" - - # Step 5: Validate refined content - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - - assert validation_result.confidence >= 0.85 - - # Step 6: Apply refinement - backlog_item.refined_body = validation_result.refined_body - backlog_item.detected_template = template.template_id - backlog_item.template_confidence = validation_result.confidence - backlog_item.apply_refinement() - - # Verify final state - assert backlog_item.body_markdown == refined_content - assert backlog_item.refinement_applied is True - assert backlog_item.detected_template == "defect_v1" - - @beartype - def test_e2e_round_trip_preservation(self, full_template_registry: TemplateRegistry) -> None: - """E2E: Verify that original provider fields are preserved.""" - original_github_issue = { - "number": 300, - "html_url": "https://github.com/org/repo/issues/300", - "title": "Feature request", - "body": "We need this feature", - "state": "open", - "comments": 5, - "milestone": {"title": "Sprint 1"}, - "user": {"login": "requester"}, - } - - backlog_item = convert_github_issue_to_backlog_item(original_github_issue) - - # Verify provider fields are preserved - assert backlog_item.provider_fields["number"] == "300" - assert backlog_item.provider_fields["comments"] == 5 - assert backlog_item.provider_fields["milestone"] is not None - - # Refine the item - refiner = BacklogAIRefiner() - template = full_template_registry.get_template("user_story_v1") - assert template is not None - - refined_content = """## As a -user - -## I want -this feature - -## So that -I can accomplish my goal - -## Acceptance Criteria -- Feature is available""" - - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - - backlog_item.refined_body = validation_result.refined_body - backlog_item.apply_refinement() - - # Verify provider fields are still preserved after refinement - assert backlog_item.provider_fields["number"] == "300" - assert backlog_item.provider_fields["comments"] == 5 - assert "milestone" in backlog_item.provider_fields - - @beartype - def test_e2e_sprint_release_extraction(self, full_template_registry: TemplateRegistry) -> None: - """E2E: Verify sprint and release extraction from GitHub milestones and ADO iteration paths.""" - # Test GitHub milestone extraction - github_issue_with_sprint = { - "number": 400, - "html_url": "https://github.com/org/repo/issues/400", - "title": "Test Issue", - "body": "", - "state": "open", - "milestone": {"title": "Sprint 3"}, - } - - backlog_item = convert_github_issue_to_backlog_item(github_issue_with_sprint) - assert backlog_item.sprint == "Sprint 3" - assert backlog_item.release is None - - # Test GitHub release milestone - github_issue_with_release = { - "number": 401, - "html_url": "https://github.com/org/repo/issues/401", - "title": "Test Issue", - "body": "", - "state": "open", - "milestone": {"title": "Release 2.0"}, - } - - backlog_item = convert_github_issue_to_backlog_item(github_issue_with_release) - assert backlog_item.release == "Release 2.0" - assert backlog_item.sprint is None - - # Test ADO iteration path extraction - ado_item_with_sprint_release = { - "id": 500, - "url": "https://dev.azure.com/org/proj/_apis/wit/workitems/500", - "fields": { - "System.Title": "Test Work Item", - "System.Description": "", - "System.State": "New", - "System.IterationPath": "Project\\Release 1\\Sprint 1", - }, - } - - backlog_item = convert_ado_work_item_to_backlog_item(ado_item_with_sprint_release) - assert backlog_item.sprint == "Sprint 1" - assert backlog_item.release == "Release 1" - assert backlog_item.iteration == "Project\\Release 1\\Sprint 1" - - @beartype - def test_e2e_template_resolution_with_filters(self, full_template_registry: TemplateRegistry) -> None: - """E2E: Verify template resolution with persona/framework/provider filters.""" - # Add framework-specific template - scrum_template = full_template_registry.get_template("user_story_v1") - if scrum_template: - # Create a scrum-specific version - from specfact_cli.templates.registry import BacklogTemplate - - scrum_story = BacklogTemplate( - template_id="scrum_story_v1", - name="Scrum User Story", - framework="scrum", - required_sections=scrum_template.required_sections, - body_patterns=scrum_template.body_patterns, - ) - full_template_registry.register_template(scrum_story) - - detector = TemplateDetector(full_template_registry) - - item = convert_github_issue_to_backlog_item( - { - "number": 600, - "html_url": "https://github.com/org/repo/issues/600", - "title": "User Story: Add feature", - "body": "## As a\nuser\n\n## I want\nto add features", - "state": "open", - } - ) - - # Test with framework filter - result = detector.detect_template(item, provider="github", framework="scrum") - # Should match scrum template if available, otherwise default - assert result.template_id is not None - assert result.confidence >= 0.5 diff --git a/tests/integration/backlog/test_additional_commands_e2e.py b/tests/integration/backlog/test_additional_commands_e2e.py deleted file mode 100644 index a10f366c..00000000 --- a/tests/integration/backlog/test_additional_commands_e2e.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Integration-style tests for additional backlog commands.""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path -from typing import Any - -from typer.testing import CliRunner - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) - -from backlog_core.main import backlog_app - -from specfact_cli.adapters.registry import AdapterRegistry - - -class _FakeBacklogAdapter: - def fetch_all_issues(self, project_id: str, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - _ = project_id, filters - return [ - {"id": "1", "key": "FEATURE-1", "title": "Feature one", "type": "feature", "status": "done"}, - {"id": "2", "key": "TASK-2", "title": "Task two", "type": "task", "status": "in progress"}, - ] - - def fetch_relationships(self, project_id: str) -> list[dict[str, Any]]: - _ = project_id - return [{"source_id": "1", "target_id": "2", "type": "blocks"}] - - def create_issue(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: - _ = project_id, payload - return {"id": "3", "key": "TASK-3", "url": "https://example.test/issues/3"} - - -def _write_baseline(path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps( - { - "provider": "github", - "project_key": "demo/project", - "items": { - "1": { - "id": "1", - "key": "FEATURE-1", - "title": "Feature one", - "type": "feature", - "status": "todo", - } - }, - "dependencies": [], - } - ), - encoding="utf-8", - ) - - -def test_backlog_additional_commands_diff_promote_and_release_notes(tmp_path: Path, monkeypatch) -> None: - runner = CliRunner() - baseline_file = tmp_path / ".specfact" / "backlog-baseline.json" - _write_baseline(baseline_file) - release_notes = tmp_path / "release-notes.md" - - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: _FakeBacklogAdapter()) - - diff_result = runner.invoke( - backlog_app, - [ - "diff", - "--project-id", - "demo/project", - "--adapter", - "github", - "--baseline-file", - str(baseline_file), - "--template", - "github_projects", - ], - ) - assert diff_result.exit_code == 0 - - promote_result = runner.invoke( - backlog_app, - [ - "promote", - "--project-id", - "demo/project", - "--adapter", - "github", - "--item-id", - "2", - "--to-status", - "done", - "--template", - "github_projects", - ], - ) - assert promote_result.exit_code == 0 - - release_result = runner.invoke( - backlog_app, - [ - "generate-release-notes", - "--project-id", - "demo/project", - "--adapter", - "github", - "--output", - str(release_notes), - "--template", - "github_projects", - ], - ) - assert release_result.exit_code == 0 - assert release_notes.exists() diff --git a/tests/integration/backlog/test_ado_e2e.py b/tests/integration/backlog/test_ado_e2e.py deleted file mode 100644 index af8e67e8..00000000 --- a/tests/integration/backlog/test_ado_e2e.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Integration-style test for backlog trace-impact command with ADO adapter.""" - -from __future__ import annotations - -import sys -from pathlib import Path -from typing import Any - -from typer.testing import CliRunner - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) - -from backlog_core.main import backlog_app - -from specfact_cli.adapters.registry import AdapterRegistry - - -class _FakeAdoAdapter: - def fetch_all_issues(self, project_id: str, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - _ = project_id, filters - return [ - {"id": "100", "key": "ADO-100", "title": "Epic", "type": "Epic", "status": "New"}, - { - "id": "101", - "key": "ADO-101", - "title": "Story", - "type": "User Story", - "status": "Active", - }, - ] - - def fetch_relationships(self, project_id: str) -> list[dict[str, Any]]: - _ = project_id - return [{"source_id": "100", "target_id": "101", "type": "blocks"}] - - def create_issue(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: - _ = project_id, payload - return {"id": "102", "key": "ADO-102", "url": "https://example.test/workitems/102"} - - -def test_backlog_trace_impact_ado_flow(monkeypatch) -> None: - runner = CliRunner() - - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: _FakeAdoAdapter()) - - result = runner.invoke( - backlog_app, - [ - "trace-impact", - "100", - "--project-id", - "demo/project", - "--adapter", - "ado", - "--template", - "ado_scrum", - ], - ) - - assert result.exit_code == 0 - assert "Estimated impact count" in result.stdout diff --git a/tests/integration/backlog/test_backlog_filtering_integration.py b/tests/integration/backlog/test_backlog_filtering_integration.py deleted file mode 100644 index 134deb79..00000000 --- a/tests/integration/backlog/test_backlog_filtering_integration.py +++ /dev/null @@ -1,278 +0,0 @@ -""" -Integration tests for backlog filtering with GitHub issues. - -Tests the complete filtering workflow with realistic GitHub issue data, -including open and closed issues with various labels, assignees, and milestones. -""" - -from __future__ import annotations - -from typing import Any - -import pytest -from beartype import beartype - - -pytest.importorskip("specfact_backlog.backlog.commands") -from specfact_backlog.backlog.commands import _apply_filters - -from specfact_cli.backlog.converter import convert_github_issue_to_backlog_item -from specfact_cli.models.backlog_item import BacklogItem - - -@pytest.fixture -def realistic_github_issues() -> list[dict[str, Any]]: - """Create realistic GitHub issues for integration testing.""" - return [ - # Open issues - { - "number": 101, - "html_url": "https://github.com/org/repo/issues/101", - "title": "Add user authentication feature", - "body": "We need to implement user login and registration", - "state": "open", - "assignees": [{"login": "alice"}], - "labels": [{"name": "feature"}, {"name": "enhancement"}, {"name": "priority-high"}], - "milestone": {"title": "Sprint 2025-01"}, - "created_at": "2025-01-15T10:00:00Z", - "updated_at": "2025-01-20T14:30:00Z", - }, - { - "number": 102, - "html_url": "https://github.com/org/repo/issues/102", - "title": "Fix login button not responding", - "body": "The login button doesn't work on mobile devices", - "state": "open", - "assignees": [{"login": "bob"}], - "labels": [{"name": "bug"}, {"name": "priority-high"}], - "milestone": None, - "created_at": "2025-01-16T09:00:00Z", - "updated_at": "2025-01-20T15:00:00Z", - }, - { - "number": 103, - "html_url": "https://github.com/org/repo/issues/103", - "title": "Research OAuth integration options", - "body": "We need to evaluate OAuth providers for SSO", - "state": "open", - "assignees": [{"login": "charlie"}], - "labels": [{"name": "spike"}, {"name": "research"}], - "milestone": {"title": "Sprint 2025-01"}, - "created_at": "2025-01-17T11:00:00Z", - "updated_at": "2025-01-19T16:00:00Z", - }, - # Closed issues - { - "number": 201, - "html_url": "https://github.com/org/repo/issues/201", - "title": "Add password reset functionality", - "body": "Users need to be able to reset their passwords", - "state": "closed", - "assignees": [{"login": "alice"}], - "labels": [{"name": "feature"}, {"name": "enhancement"}], - "milestone": {"title": "v1.2.0"}, - "created_at": "2024-12-10T08:00:00Z", - "updated_at": "2024-12-20T17:00:00Z", - "closed_at": "2024-12-20T17:00:00Z", - }, - { - "number": 202, - "html_url": "https://github.com/org/repo/issues/202", - "title": "Fix memory leak in authentication service", - "body": "Memory usage increases over time in auth service", - "state": "closed", - "assignees": [{"login": "bob"}], - "labels": [{"name": "bug"}, {"name": "performance"}], - "milestone": {"title": "v1.2.0"}, - "created_at": "2024-12-12T10:00:00Z", - "updated_at": "2024-12-18T14:00:00Z", - "closed_at": "2024-12-18T14:00:00Z", - }, - { - "number": 203, - "html_url": "https://github.com/org/repo/issues/203", - "title": "Update authentication documentation", - "body": "Documentation needs to be updated for new auth flow", - "state": "closed", - "assignees": [{"login": "charlie"}], - "labels": [{"name": "documentation"}], - "milestone": None, - "created_at": "2024-12-15T09:00:00Z", - "updated_at": "2024-12-19T11:00:00Z", - "closed_at": "2024-12-19T11:00:00Z", - }, - ] - - -@pytest.fixture -def backlog_items_from_github(realistic_github_issues: list[dict[str, Any]]) -> list[BacklogItem]: - """Convert realistic GitHub issues to BacklogItem instances.""" - return [convert_github_issue_to_backlog_item(issue) for issue in realistic_github_issues] - - -class TestBacklogFilteringIntegration: - """Integration tests for backlog filtering with GitHub issues.""" - - @beartype - def test_filter_open_issues_only(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering to get only open GitHub issues.""" - filtered = _apply_filters(backlog_items_from_github, state="open") - - assert len(filtered) == 3 - assert all(item.state.lower() == "open" for item in filtered) - assert all(item.id in ["101", "102", "103"] for item in filtered) - - @beartype - def test_filter_closed_issues_only(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering to get only closed GitHub issues.""" - filtered = _apply_filters(backlog_items_from_github, state="closed") - - assert len(filtered) == 3 - assert all(item.state.lower() == "closed" for item in filtered) - assert all(item.id in ["201", "202", "203"] for item in filtered) - - @beartype - def test_filter_open_issues_with_feature_label(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering open issues with feature label (common workflow).""" - filtered = _apply_filters(backlog_items_from_github, state="open", labels=["feature"]) - - assert len(filtered) == 1 - assert filtered[0].id == "101" - assert filtered[0].state.lower() == "open" - assert "feature" in [tag.lower() for tag in filtered[0].tags] - - @beartype - def test_filter_closed_issues_with_bug_label(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering closed issues with bug label (common workflow).""" - filtered = _apply_filters(backlog_items_from_github, state="closed", labels=["bug"]) - - assert len(filtered) == 1 - assert filtered[0].id == "202" - assert filtered[0].state.lower() == "closed" - assert "bug" in [tag.lower() for tag in filtered[0].tags] - - @beartype - def test_filter_by_assignee_alice(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering by assignee (alice).""" - filtered = _apply_filters(backlog_items_from_github, assignee="alice") - - assert len(filtered) == 2 - assert all("alice" in [a.lower() for a in item.assignees] for item in filtered) - assert all(item.id in ["101", "201"] for item in filtered) - - @beartype - def test_filter_open_issues_by_assignee(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering open issues assigned to specific person.""" - filtered = _apply_filters(backlog_items_from_github, state="open", assignee="bob") - - assert len(filtered) == 1 - assert filtered[0].id == "102" - assert filtered[0].state.lower() == "open" - assert "bob" in [a.lower() for a in filtered[0].assignees] - - @beartype - def test_filter_by_sprint_milestone(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering by sprint milestone.""" - filtered = _apply_filters(backlog_items_from_github, sprint="Sprint 2025-01") - - assert len(filtered) == 2 - assert all(item.sprint == "Sprint 2025-01" for item in filtered) - assert all(item.id in ["101", "103"] for item in filtered) - - @beartype - def test_filter_by_release_milestone(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering by release milestone.""" - filtered = _apply_filters(backlog_items_from_github, release="v1.2.0") - - assert len(filtered) == 2 - assert all(item.release == "v1.2.0" for item in filtered) - assert all(item.id in ["201", "202"] for item in filtered) - - @beartype - def test_filter_open_issues_in_sprint(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering open issues in a specific sprint.""" - filtered = _apply_filters(backlog_items_from_github, state="open", sprint="Sprint 2025-01") - - assert len(filtered) == 2 - assert all(item.state.lower() == "open" for item in filtered) - assert all(item.sprint == "Sprint 2025-01" for item in filtered) - assert all(item.id in ["101", "103"] for item in filtered) - - @beartype - def test_filter_closed_issues_in_release(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering closed issues in a specific release.""" - filtered = _apply_filters(backlog_items_from_github, state="closed", release="v1.2.0") - - assert len(filtered) == 2 - assert all(item.state.lower() == "closed" for item in filtered) - assert all(item.release == "v1.2.0" for item in filtered) - assert all(item.id in ["201", "202"] for item in filtered) - - @beartype - def test_filter_multiple_labels(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering by multiple labels (OR logic).""" - filtered = _apply_filters(backlog_items_from_github, labels=["feature", "bug"]) - - assert len(filtered) == 4 - assert all( - any(label in [tag.lower() for tag in item.tags] for label in ["feature", "bug"]) for item in filtered - ) - assert all(item.id in ["101", "102", "201", "202"] for item in filtered) - - @beartype - def test_filter_open_issues_with_priority_high(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filtering open issues with priority-high label.""" - filtered = _apply_filters(backlog_items_from_github, state="open", labels=["priority-high"]) - - assert len(filtered) == 2 - assert all(item.state.lower() == "open" for item in filtered) - assert all("priority-high" in [tag.lower() for tag in item.tags] for item in filtered) - assert all(item.id in ["101", "102"] for item in filtered) - - @beartype - def test_filter_complex_combination(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test complex filter combination: open + feature + assigned to alice.""" - filtered = _apply_filters(backlog_items_from_github, state="open", labels=["feature"], assignee="alice") - - assert len(filtered) == 1 - assert filtered[0].id == "101" - assert filtered[0].state.lower() == "open" - assert "feature" in [tag.lower() for tag in filtered[0].tags] - assert "alice" in [a.lower() for a in filtered[0].assignees] - - @beartype - def test_filter_no_matches(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test filter combination that matches no items.""" - filtered = _apply_filters(backlog_items_from_github, state="open", labels=["documentation"], assignee="alice") - - assert len(filtered) == 0 - - @beartype - def test_filter_case_insensitive_github_issues(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test that filtering works case-insensitively with GitHub issues.""" - filtered_upper = _apply_filters(backlog_items_from_github, state="OPEN", labels=["FEATURE"]) - filtered_lower = _apply_filters(backlog_items_from_github, state="open", labels=["feature"]) - - assert len(filtered_upper) == len(filtered_lower) - assert len(filtered_upper) == 1 # One open issue with feature label (AND logic: state AND labels) - - @beartype - def test_filter_preserves_all_item_fields(self, backlog_items_from_github: list[BacklogItem]) -> None: - """Test that filtering preserves all BacklogItem fields.""" - original_item = backlog_items_from_github[0] - filtered = _apply_filters([original_item], state="open") - - assert len(filtered) == 1 - filtered_item = filtered[0] - - # Verify all fields are preserved - assert filtered_item.id == original_item.id - assert filtered_item.provider == original_item.provider - assert filtered_item.url == original_item.url - assert filtered_item.title == original_item.title - assert filtered_item.body_markdown == original_item.body_markdown - assert filtered_item.state == original_item.state - assert filtered_item.assignees == original_item.assignees - assert filtered_item.tags == original_item.tags - assert filtered_item.sprint == original_item.sprint - assert filtered_item.release == original_item.release diff --git a/tests/integration/backlog/test_backlog_refine_sync_chaining.py b/tests/integration/backlog/test_backlog_refine_sync_chaining.py deleted file mode 100644 index 17963b40..00000000 --- a/tests/integration/backlog/test_backlog_refine_sync_chaining.py +++ /dev/null @@ -1,268 +0,0 @@ -""" -Integration tests for command chaining: backlog refine → sync bridge. - -Tests the complete workflow where a backlog item is refined and then synced -to an external tool using the bridge sync command. -""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -from beartype import beartype - -from specfact_cli.backlog.adapters.base import BacklogAdapter -from specfact_cli.backlog.ai_refiner import BacklogAIRefiner -from specfact_cli.backlog.converter import convert_github_issue_to_backlog_item -from specfact_cli.backlog.template_detector import TemplateDetector -from specfact_cli.models.backlog_item import BacklogItem -from specfact_cli.templates.registry import TemplateRegistry - - -@pytest.fixture -def template_registry(tmp_path: Path) -> TemplateRegistry: - """Create template registry with user story template.""" - registry = TemplateRegistry() - - defaults_dir = tmp_path / "templates" / "defaults" - defaults_dir.mkdir(parents=True) - - (defaults_dir / "user_story_v1.yaml").write_text("""template_id: user_story_v1 -name: User Story -scope: corporate -required_sections: - - As a - - I want - - So that - - Acceptance Criteria -body_patterns: - as_a: "As a [^,]+ I want" -title_patterns: - - "^.*[Uu]ser [Ss]tory.*$" -""") - - registry.load_templates_from_directory(defaults_dir) - return registry - - -@pytest.fixture -def mock_github_adapter() -> MagicMock: - """Create a mock GitHub adapter that implements BacklogAdapter interface.""" - adapter = MagicMock(spec=BacklogAdapter) - adapter.provider = "github" - - # Mock fetch_backlog_items - def mock_fetch(items: list[dict]) -> list[BacklogItem]: - return [convert_github_issue_to_backlog_item(item) for item in items] - - adapter.fetch_backlog_items = MagicMock(side_effect=lambda filters: mock_fetch([])) - adapter.update_backlog_item = MagicMock(return_value=True) - adapter.add_comment = MagicMock(return_value=True) - return adapter - - -class TestBacklogRefineSyncChaining: - """Integration tests for backlog refine → sync bridge command chaining.""" - - @beartype - def test_refine_then_sync_workflow( - self, template_registry: TemplateRegistry, mock_github_adapter: MagicMock, tmp_path: Path - ) -> None: - """Test complete workflow: refine backlog item → sync to external tool.""" - # Step 1: Fetch backlog item - github_issue = { - "number": 123, - "html_url": "https://github.com/org/repo/issues/123", - "title": "We need authentication feature", - "body": "Users want to log in. Can we add this?", - "state": "open", - "assignees": [{"login": "dev1"}], - "labels": [{"name": "feature"}], - "created_at": "2024-01-18T10:00:00Z", - "updated_at": "2024-01-19T14:30:00Z", - } - - backlog_item = convert_github_issue_to_backlog_item(github_issue) - - # Step 2: Detect template (simulating backlog refine command) - detector = TemplateDetector(template_registry) - detection_result = detector.detect_template(backlog_item) - - # If no template detected, use default user_story_v1 template - template_id = detection_result.template_id or "user_story_v1" - assert backlog_item.needs_refinement is True - - # Step 3: Generate refinement prompt (simulating backlog refine command) - refiner = BacklogAIRefiner() - template = template_registry.get_template(template_id) - assert template is not None - - prompt = refiner.generate_refinement_prompt(backlog_item, template) - assert backlog_item.title in prompt - - # Step 4: Simulate IDE AI copilot refinement - refined_content = """## As a -registered user - -## I want -to authenticate and log in to the system - -## So that -I can access my account and protected resources - -## Acceptance Criteria -- User can enter username and password -- User can click login button -- System validates credentials -- User is redirected to dashboard on successful login""" - - # Step 5: Validate refined content (simulating backlog refine command) - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - - assert validation_result.confidence >= 0.85 - - # Step 6: Apply refinement (simulating backlog refine command with --write) - backlog_item.refined_body = validation_result.refined_body - backlog_item.detected_template = template.template_id - backlog_item.template_confidence = validation_result.confidence - backlog_item.apply_refinement() - - assert backlog_item.body_markdown == refined_content - assert backlog_item.refinement_applied is True - - # Step 7: Sync refined item to external tool (simulating sync bridge command) - # This simulates: specfact project sync bridge --adapter github --backlog-ids 123 - with patch("specfact_cli.adapters.registry.AdapterRegistry.get_adapter", return_value=mock_github_adapter): - # Update the backlog item in the external tool - success = mock_github_adapter.update_backlog_item(backlog_item) - - assert success is True - mock_github_adapter.update_backlog_item.assert_called_once() - call_args = mock_github_adapter.update_backlog_item.call_args[0][0] - assert isinstance(call_args, BacklogItem) - assert call_args.id == "123" - assert call_args.body_markdown == refined_content - assert call_args.refinement_applied is True - - @beartype - def test_refine_then_sync_with_openspec_comment( - self, template_registry: TemplateRegistry, mock_github_adapter: MagicMock, tmp_path: Path - ) -> None: - """Test refine → sync workflow with OpenSpec comment integration.""" - github_issue = { - "number": 456, - "html_url": "https://github.com/org/repo/issues/456", - "title": "Add new feature", - "body": "We need this feature", - "state": "open", - } - - backlog_item = convert_github_issue_to_backlog_item(github_issue) - - # Refine the item - detector = TemplateDetector(template_registry) - detection_result = detector.detect_template(backlog_item) - template_id = detection_result.template_id or "user_story_v1" - template = template_registry.get_template(template_id) - assert template is not None - - refiner = BacklogAIRefiner() - refined_content = """## As a -user - -## I want -this feature - -## So that -I can accomplish my goal - -## Acceptance Criteria -- Feature is available""" - - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - backlog_item.refined_body = validation_result.refined_body - backlog_item.apply_refinement() - - # Simulate sync bridge with OpenSpec comment (--openspec-comment flag) - # This simulates: specfact project sync bridge --adapter github --backlog-ids 456 --openspec-comment - openspec_comment = ( - "OpenSpec change proposal: add-new-feature\nSee: https://openspec.example.com/changes/add-new-feature" - ) - - with patch("specfact_cli.adapters.registry.AdapterRegistry.get_adapter", return_value=mock_github_adapter): - # Update backlog item - mock_github_adapter.update_backlog_item(backlog_item) - - # Add OpenSpec comment (preserving original body) - mock_github_adapter.add_comment(backlog_item.id, openspec_comment) - - # Verify both operations were called - mock_github_adapter.update_backlog_item.assert_called_once() - mock_github_adapter.add_comment.assert_called_once_with(backlog_item.id, openspec_comment) - - @beartype - def test_refine_then_sync_cross_adapter(self, template_registry: TemplateRegistry, tmp_path: Path) -> None: - """Test refine from GitHub → sync to ADO (cross-adapter sync).""" - # Step 1: Refine GitHub issue - github_issue = { - "number": 789, - "html_url": "https://github.com/org/repo/issues/789", - "title": "User Story: Improve performance", - "body": "The app is slow. We need to optimize it.", - "state": "open", - } - - backlog_item = convert_github_issue_to_backlog_item(github_issue) - - # Refine using template - detector = TemplateDetector(template_registry) - detection_result = detector.detect_template(backlog_item) - template_id = detection_result.template_id or "user_story_v1" - template = template_registry.get_template(template_id) - assert template is not None - - refiner = BacklogAIRefiner() - refined_content = """## As a -user - -## I want -faster application performance - -## So that -I can complete my tasks without delays - -## Acceptance Criteria -- Page load time < 2 seconds -- API response time < 500ms""" - - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - backlog_item.refined_body = validation_result.refined_body - backlog_item.apply_refinement() - - # Step 2: Create mock ADO adapter for cross-adapter sync - mock_ado_adapter = MagicMock(spec=BacklogAdapter) - mock_ado_adapter.provider = "ado" - mock_ado_adapter.update_backlog_item = MagicMock(return_value=True) - - # Step 3: Sync to ADO (simulating: specfact project sync bridge --adapter ado --backlog-ids 789) - with patch("specfact_cli.adapters.registry.AdapterRegistry.get_adapter", return_value=mock_ado_adapter): - success = mock_ado_adapter.update_backlog_item(backlog_item) - - assert success is True - mock_ado_adapter.update_backlog_item.assert_called_once() - - # Verify the refined content is preserved in cross-adapter sync - call_args = mock_ado_adapter.update_backlog_item.call_args[0][0] - assert call_args.body_markdown == refined_content - assert call_args.refinement_applied is True - # Original provider info should be preserved - assert call_args.provider == "github" - assert call_args.id == "789" diff --git a/tests/integration/backlog/test_backlog_refinement_flow.py b/tests/integration/backlog/test_backlog_refinement_flow.py deleted file mode 100644 index 718079de..00000000 --- a/tests/integration/backlog/test_backlog_refinement_flow.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -Integration tests for backlog refinement flow. - -Tests the complete flow: fetch → detect → refine → validate → apply. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from beartype import beartype - -from specfact_cli.backlog.ai_refiner import BacklogAIRefiner -from specfact_cli.backlog.converter import convert_github_issue_to_backlog_item -from specfact_cli.backlog.template_detector import TemplateDetector -from specfact_cli.templates.registry import TemplateRegistry - - -@pytest.fixture -def template_registry_with_defaults(tmp_path: Path) -> TemplateRegistry: - """Create template registry with default templates loaded.""" - registry = TemplateRegistry() - - # Create defaults directory structure - defaults_dir = tmp_path / "templates" / "defaults" - defaults_dir.mkdir(parents=True) - - # Create user story template - user_story_file = defaults_dir / "user_story_v1.yaml" - user_story_file.write_text("""template_id: user_story_v1 -name: User Story -description: Standard user story template -scope: corporate -required_sections: - - As a - - I want - - So that - - Acceptance Criteria -optional_sections: - - Notes -body_patterns: - as_a: "As a [^,]+ I want" -title_patterns: - - "^.*[Uu]ser [Ss]tory.*$" -""") - - registry.load_templates_from_directory(defaults_dir) - return registry - - -class TestBacklogRefinementFlow: - """Test complete backlog refinement flow.""" - - @beartype - def test_refine_arbitrary_github_issue_to_user_story( - self, template_registry_with_defaults: TemplateRegistry - ) -> None: - """Test refining arbitrary GitHub issue into user story template.""" - # Step 1: Convert arbitrary GitHub issue to BacklogItem - arbitrary_issue = { - "number": 123, - "html_url": "https://github.com/test/repo/issues/123", - "title": "Need login feature", - "body": """Hey team, -We need to add a login feature. Users are asking for it. -Can someone implement this? - -Thanks!""", - "state": "open", - "assignees": [], - "labels": [], - } - - backlog_item = convert_github_issue_to_backlog_item(arbitrary_issue) - - # Step 2: Detect template (should have low/no confidence) - detector = TemplateDetector(template_registry_with_defaults) - detection_result = detector.detect_template(backlog_item) - - assert detection_result.confidence < 0.6 # Low confidence for arbitrary input - assert backlog_item.needs_refinement is True - - # Step 3: Generate refinement prompt - refiner = BacklogAIRefiner() - template = template_registry_with_defaults.get_template("user_story_v1") - assert template is not None - - prompt = refiner.generate_refinement_prompt(backlog_item, template) - - assert "Need login feature" in prompt - assert "login feature" in prompt - assert "As a" in prompt - assert "I want" in prompt - - # Step 4: Simulate refined content from IDE AI copilot - refined_content = """## As a -user - -## I want -to log in to the system - -## So that -I can access my account and protected resources - -## Acceptance Criteria -- User can enter username and password -- User can click login button -- System validates credentials -- User is redirected to dashboard on success""" - - # Step 5: Validate refined content - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - - assert validation_result.confidence >= 0.85 - assert validation_result.has_todo_markers is False - - # Step 6: Apply refinement - backlog_item.refined_body = validation_result.refined_body - backlog_item.detected_template = template.template_id - backlog_item.template_confidence = validation_result.confidence - backlog_item.apply_refinement() - - assert backlog_item.body_markdown == refined_content - assert backlog_item.refinement_applied is True - assert backlog_item.detected_template == "user_story_v1" - assert backlog_item.template_confidence == validation_result.confidence - - @beartype - def test_refine_arbitrary_input_with_todo_markers(self, template_registry_with_defaults: TemplateRegistry) -> None: - """Test refining arbitrary input that results in TODO markers.""" - arbitrary_issue = { - "number": 456, - "html_url": "https://github.com/test/repo/issues/456", - "title": "Add feature", - "body": "We need to add something", - "state": "open", - } - - backlog_item = convert_github_issue_to_backlog_item(arbitrary_issue) - refiner = BacklogAIRefiner() - template = template_registry_with_defaults.get_template("user_story_v1") - assert template is not None - - # Simulate refined content with TODO markers (missing information) - refined_content = """## As a -[TODO: specify user type] - -## I want -to add something - -## So that -[TODO: specify benefit] - -## Acceptance Criteria -- [TODO: add criteria]""" - - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - - # Should have lower confidence due to TODO markers - assert validation_result.confidence < 0.85 - assert validation_result.has_todo_markers is True - - @beartype - def test_refine_arbitrary_input_with_notes_section(self, template_registry_with_defaults: TemplateRegistry) -> None: - """Test refining arbitrary input that results in NOTES section.""" - arbitrary_issue = { - "number": 789, - "html_url": "https://github.com/test/repo/issues/789", - "title": "Conflicting requirements", - "body": """We need to implement X, but also Y. -There's some confusion about which one to prioritize.""", - "state": "open", - } - - backlog_item = convert_github_issue_to_backlog_item(arbitrary_issue) - refiner = BacklogAIRefiner() - template = template_registry_with_defaults.get_template("user_story_v1") - assert template is not None - - # Simulate refined content with NOTES section (ambiguity detected) - refined_content = """## As a -user - -## I want -to have feature X implemented - -## So that -I can accomplish my goal - -## Acceptance Criteria -- Feature X is available - -## NOTES -There's ambiguity about whether to prioritize X or Y. -The original request mentioned both, but they may conflict.""" - - validation_result = refiner.validate_and_score_refinement( - refined_content, backlog_item.body_markdown, template, backlog_item - ) - - # Should have lower confidence due to NOTES section - assert validation_result.confidence < 0.85 - assert validation_result.has_notes_section is True diff --git a/tests/integration/backlog/test_custom_field_mapping.py b/tests/integration/backlog/test_custom_field_mapping.py deleted file mode 100644 index b1c94ced..00000000 --- a/tests/integration/backlog/test_custom_field_mapping.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -Integration tests for CLI with custom field mappings. - -Tests the complete flow of using custom field mapping files with the backlog refine command. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -import yaml -from typer.testing import CliRunner - -from specfact_cli.cli import app - - -@pytest.fixture -def custom_mapping_file(tmp_path: Path) -> Path: - """Create a custom field mapping file for testing.""" - mapping_file = tmp_path / "ado_custom.yaml" - mapping_data = { - "framework": "scrum", - "field_mappings": { - "System.Description": "description", - "Custom.AcceptanceCriteria": "acceptance_criteria", - "Custom.StoryPoints": "story_points", - "Custom.BusinessValue": "business_value", - "Custom.Priority": "priority", - "System.WorkItemType": "work_item_type", - }, - "work_item_type_mappings": { - "Product Backlog Item": "User Story", - "Bug": "Bug", - }, - } - mapping_file.write_text(yaml.dump(mapping_data), encoding="utf-8") - return mapping_file - - -@pytest.fixture -def invalid_mapping_file(tmp_path: Path) -> Path: - """Create an invalid custom field mapping file for testing.""" - mapping_file = tmp_path / "invalid.yaml" - mapping_file.write_text("invalid: yaml: content: [", encoding="utf-8") - return mapping_file - - -class TestCustomFieldMappingCLI: - """Integration tests for CLI with custom field mappings.""" - - def test_custom_field_mapping_file_validation_success(self, custom_mapping_file: Path) -> None: - """Test that valid custom field mapping file is accepted.""" - runner = CliRunner() - # Use --help to test that the option exists and file validation works - # (actual refine command would need real adapter setup) - result = runner.invoke( - app, - [ - "backlog", - "refine", - "ado", - "--ado-org", - "test-org", - "--ado-project", - "test-project", - "--custom-field-mapping", - str(custom_mapping_file), - "--help", - ], - ) - # Should not error on file validation (help is shown before validation) - assert result.exit_code in (0, 2) # 0 = success, 2 = typer help exit - - def test_custom_field_mapping_file_validation_file_not_found(self) -> None: - """Test that missing custom field mapping file is rejected.""" - runner = CliRunner() - result = runner.invoke( - app, - [ - "backlog", - "refine", - "ado", - "--ado-org", - "test-org", - "--ado-project", - "test-project", - "--custom-field-mapping", - "/nonexistent/file.yaml", - ], - catch_exceptions=False, # Don't catch exceptions to avoid timeout - ) - # Should exit with error code (validation happens before adapter setup) - assert result.exit_code != 0 - out = result.output or result.stdout or "" - assert "not found" in out.lower() or "error" in out.lower() or "Error" in out - - def test_custom_field_mapping_file_validation_invalid_format(self, invalid_mapping_file: Path) -> None: - """Test that invalid custom field mapping file format is rejected.""" - runner = CliRunner() - result = runner.invoke( - app, - [ - "backlog", - "refine", - "ado", - "--ado-org", - "test-org", - "--ado-project", - "test-project", - "--custom-field-mapping", - str(invalid_mapping_file), - ], - ) - assert result.exit_code != 0 - out = result.output or result.stdout or "" - assert "invalid" in out.lower() or "error" in out.lower() - - def test_custom_field_mapping_environment_variable( - self, custom_mapping_file: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Test that custom field mapping can be set via environment variable.""" - monkeypatch.setenv("SPECFACT_ADO_CUSTOM_MAPPING", str(custom_mapping_file)) - # The converter should use the environment variable - from specfact_cli.backlog.converter import convert_ado_work_item_to_backlog_item - - item_data = { - "id": "123", - "url": "https://dev.azure.com/test/org/project/_workitems/edit/123", - "fields": { - "System.Title": "Test Item", - "System.Description": "Description", - "Custom.StoryPoints": 8, # Using custom field - "Custom.BusinessValue": 50, # Using custom field - }, - } - - # Should use custom mapping from environment variable - backlog_item = convert_ado_work_item_to_backlog_item(item_data, provider="ado") - assert backlog_item.story_points == 8 - assert backlog_item.business_value == 50 - - def test_custom_field_mapping_parameter_overrides_environment( - self, custom_mapping_file: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Test that CLI parameter overrides environment variable.""" - # Create another mapping file - other_mapping_file = tmp_path / "other_mapping.yaml" - other_mapping_data = { - "field_mappings": { - "System.Description": "description", - "Other.StoryPoints": "story_points", - }, - } - other_mapping_file.write_text(yaml.dump(other_mapping_data), encoding="utf-8") - - # Set environment variable to one file - monkeypatch.setenv("SPECFACT_ADO_CUSTOM_MAPPING", str(custom_mapping_file)) - - # CLI parameter should override environment variable - # (This is tested by the fact that the parameter sets the env var) - runner = CliRunner() - result = runner.invoke( - app, - [ - "backlog", - "refine", - "ado", - "--ado-org", - "test-org", - "--ado-project", - "test-project", - "--custom-field-mapping", - str(other_mapping_file), - "--help", - ], - ) - # Should validate the parameter file, not the environment variable file - assert result.exit_code in (0, 2) diff --git a/tests/integration/backlog/test_delta_e2e.py b/tests/integration/backlog/test_delta_e2e.py deleted file mode 100644 index 149b3b43..00000000 --- a/tests/integration/backlog/test_delta_e2e.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Integration-style tests for backlog delta subcommand suite.""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path -from typing import Any - -from typer.testing import CliRunner - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) - -from backlog_core.main import backlog_app - -from specfact_cli.adapters.registry import AdapterRegistry - - -class _FakeDeltaAdapter: - def fetch_all_issues(self, project_id: str, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - _ = project_id, filters - return [ - {"id": "1", "key": "#1", "title": "Feature", "type": "feature", "status": "done"}, - {"id": "2", "key": "#2", "title": "Task", "type": "task", "status": "in progress"}, - ] - - def fetch_relationships(self, project_id: str) -> list[dict[str, Any]]: - _ = project_id - return [{"source_id": "1", "target_id": "2", "type": "blocks"}] - - def create_issue(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: - _ = project_id, payload - return {"id": "3", "key": "#3", "url": "https://example.test/issues/3"} - - -def _write_baseline(path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps( - { - "provider": "github", - "project_key": "nold-ai/specfact-cli", - "items": { - "1": { - "id": "1", - "key": "#1", - "title": "Feature", - "type": "feature", - "status": "todo", - } - }, - "dependencies": [], - } - ), - encoding="utf-8", - ) - - -def test_backlog_delta_commands_execute_for_status_impact_cost_and_rollback(tmp_path: Path, monkeypatch) -> None: - runner = CliRunner() - baseline_file = tmp_path / ".specfact" / "backlog-baseline.json" - _write_baseline(baseline_file) - - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: _FakeDeltaAdapter()) - - status_result = runner.invoke( - backlog_app, - [ - "delta", - "status", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--baseline-file", - str(baseline_file), - "--template", - "github_projects", - ], - ) - assert status_result.exit_code == 0 - - impact_result = runner.invoke( - backlog_app, - [ - "delta", - "impact", - "2", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--template", - "github_projects", - ], - ) - assert impact_result.exit_code == 0 - - cost_result = runner.invoke( - backlog_app, - [ - "delta", - "cost-estimate", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--baseline-file", - str(baseline_file), - "--template", - "github_projects", - ], - ) - assert cost_result.exit_code == 0 - - rollback_result = runner.invoke( - backlog_app, - [ - "delta", - "rollback-analysis", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--baseline-file", - str(baseline_file), - "--template", - "github_projects", - ], - ) - assert rollback_result.exit_code == 0 diff --git a/tests/integration/backlog/test_github_e2e.py b/tests/integration/backlog/test_github_e2e.py deleted file mode 100644 index d7baa121..00000000 --- a/tests/integration/backlog/test_github_e2e.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Integration-style test for backlog analyze-deps command with GitHub adapter.""" - -from __future__ import annotations - -import sys -from pathlib import Path -from typing import Any - -from typer.testing import CliRunner - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) - -from backlog_core.main import backlog_app - -from specfact_cli.adapters.registry import AdapterRegistry - - -class _FakeGitHubAdapter: - def fetch_all_issues(self, project_id: str, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - _ = project_id, filters - return [ - {"id": "1", "key": "#1", "title": "Feature", "type": "feature", "status": "todo"}, - {"id": "2", "key": "#2", "title": "Task", "type": "task", "status": "in progress"}, - ] - - def fetch_relationships(self, project_id: str) -> list[dict[str, Any]]: - _ = project_id - return [{"source_id": "1", "target_id": "2", "type": "blocks"}] - - def create_issue(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: - _ = project_id, payload - return {"id": "3", "key": "#3", "url": "https://example.test/issues/3"} - - -def test_backlog_analyze_deps_github_flow(tmp_path: Path, monkeypatch) -> None: - runner = CliRunner() - report_path = tmp_path / "report.md" - json_path = tmp_path / "graph.json" - - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: _FakeGitHubAdapter()) - - result = runner.invoke( - backlog_app, - [ - "analyze-deps", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--template", - "github_projects", - "--output", - str(report_path), - "--json-export", - str(json_path), - ], - ) - - assert result.exit_code == 0 - assert report_path.exists() - assert json_path.exists() diff --git a/tests/integration/backlog/test_provider_enrichment_e2e.py b/tests/integration/backlog/test_provider_enrichment_e2e.py deleted file mode 100644 index 9899f04f..00000000 --- a/tests/integration/backlog/test_provider_enrichment_e2e.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Integration tests for provider enrichment paths used by backlog graph analysis.""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -from typer.testing import CliRunner - -from specfact_cli.adapters.ado import AdoAdapter -from specfact_cli.adapters.github import GitHubAdapter -from specfact_cli.models.backlog_item import BacklogItem - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) - -from backlog_core.main import backlog_app - -from specfact_cli.adapters.registry import AdapterRegistry - - -def test_analyze_deps_uses_github_enrichment_path(tmp_path: Path, monkeypatch) -> None: - """GitHub adapter enrichment should yield typed items with dependency edges in graph export.""" - adapter = GitHubAdapter(repo_owner="nold-ai", repo_name="specfact-cli", use_gh_cli=False) - monkeypatch.setattr( - adapter, - "fetch_backlog_items", - lambda _filters: [ - BacklogItem( - id="1", - provider="github", - url="https://github.com/nold-ai/specfact-cli/issues/1", - title="Core epic", - body_markdown="Body", - state="open", - tags=["epic"], - ), - BacklogItem( - id="2", - provider="github", - url="https://github.com/nold-ai/specfact-cli/issues/2", - title="Implement feature", - body_markdown="Blocked by #1", - state="open", - tags=["feature"], - ), - ], - ) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: adapter) - - runner = CliRunner() - json_path = tmp_path / "github_graph.json" - result = runner.invoke( - backlog_app, - [ - "analyze-deps", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--template", - "github_projects", - "--json-export", - str(json_path), - ], - ) - - assert result.exit_code == 0 - graph = json.loads(json_path.read_text(encoding="utf-8")) - assert len(graph["dependencies"]) >= 1 - assert any(item["type"] != "custom" for item in graph["items"].values()) - - -def test_analyze_deps_uses_ado_enrichment_path(tmp_path: Path, monkeypatch) -> None: - """ADO relation enrichment should produce non-custom dependency edges in graph export.""" - adapter = AdoAdapter(org="nold-ai", project="specfact-cli", api_token="test-token") - monkeypatch.setattr( - adapter, - "fetch_all_issues", - lambda _project_id, filters=None: [ - { - "id": "100", - "title": "Epic", - "work_item_type": "Epic", - "status": "active", - "provider_fields": { - "relations": [ - { - "rel": "System.LinkTypes.Hierarchy-Forward", - "url": "https://dev.azure.com/nold-ai/_apis/wit/workItems/101", - } - ] - }, - }, - { - "id": "101", - "title": "Story", - "work_item_type": "User Story", - "status": "new", - "provider_fields": {"relations": []}, - }, - ], - ) - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: adapter) - - runner = CliRunner() - json_path = tmp_path / "ado_graph.json" - result = runner.invoke( - backlog_app, - [ - "analyze-deps", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "ado", - "--template", - "ado_scrum", - "--json-export", - str(json_path), - ], - ) - - assert result.exit_code == 0 - graph = json.loads(json_path.read_text(encoding="utf-8")) - assert len(graph["dependencies"]) >= 1 - assert any(dep["type"] != "custom" for dep in graph["dependencies"]) diff --git a/tests/integration/backlog/test_sync_e2e.py b/tests/integration/backlog/test_sync_e2e.py deleted file mode 100644 index db4183ce..00000000 --- a/tests/integration/backlog/test_sync_e2e.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Integration-style tests for backlog sync command.""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path -from typing import Any - -from typer.testing import CliRunner - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) - -from backlog_core.main import backlog_app - -from specfact_cli.adapters.registry import AdapterRegistry - - -class _FakeGitHubSyncAdapter: - def fetch_all_issues(self, project_id: str, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - _ = project_id, filters - return [ - {"id": "1", "key": "#1", "title": "Feature", "type": "feature", "status": "done"}, - {"id": "2", "key": "#2", "title": "Task", "type": "task", "status": "in progress"}, - ] - - def fetch_relationships(self, project_id: str) -> list[dict[str, Any]]: - _ = project_id - return [{"source_id": "1", "target_id": "2", "type": "blocks"}] - - def create_issue(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: - _ = project_id, payload - return {"id": "3", "key": "#3", "url": "https://example.test/issues/3"} - - -def test_backlog_sync_generates_plan_and_updates_baseline(tmp_path: Path, monkeypatch) -> None: - runner = CliRunner() - baseline_file = tmp_path / ".specfact" / "backlog-baseline.json" - baseline_file.parent.mkdir(parents=True, exist_ok=True) - baseline_file.write_text( - json.dumps( - { - "provider": "github", - "project_key": "nold-ai/specfact-cli", - "items": { - "1": { - "id": "1", - "key": "#1", - "title": "Feature", - "type": "feature", - "status": "todo", - } - }, - "dependencies": [], - } - ), - encoding="utf-8", - ) - - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: _FakeGitHubSyncAdapter()) - monkeypatch.chdir(tmp_path) - - result = runner.invoke( - backlog_app, - [ - "sync", - "--project-id", - "nold-ai/specfact-cli", - "--adapter", - "github", - "--baseline-file", - str(baseline_file), - "--template", - "github_projects", - "--output-format", - "plan", - ], - ) - - assert result.exit_code == 0 - assert baseline_file.exists() - plans_dir = tmp_path / ".specfact" / "plans" - assert plans_dir.exists() - assert list(plans_dir.glob("backlog-*.yaml")) diff --git a/tests/integration/backlog/test_verify_readiness_e2e.py b/tests/integration/backlog/test_verify_readiness_e2e.py deleted file mode 100644 index f2373ad7..00000000 --- a/tests/integration/backlog/test_verify_readiness_e2e.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Integration-style test for verify-readiness command.""" - -from __future__ import annotations - -import sys -from pathlib import Path -from typing import Any - -from typer.testing import CliRunner - - -# ruff: noqa: E402 - - -REPO_ROOT = Path(__file__).resolve().parents[3] -sys.path.insert(0, str(REPO_ROOT / "modules" / "backlog-core" / "src")) - -from backlog_core.main import backlog_app - -from specfact_cli.adapters.registry import AdapterRegistry - - -class _FakeVerifyAdapter: - def fetch_all_issues(self, project_id: str, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]: - _ = project_id, filters - return [ - {"id": "100", "key": "A-100", "title": "Epic", "type": "epic", "status": "done"}, - {"id": "101", "key": "A-101", "title": "Story", "type": "story", "status": "done"}, - {"id": "102", "key": "A-102", "title": "Task", "type": "task", "status": "done"}, - ] - - def fetch_relationships(self, project_id: str) -> list[dict[str, Any]]: - _ = project_id - return [ - {"source_id": "100", "target_id": "101", "type": "parent_child"}, - {"source_id": "101", "target_id": "102", "type": "relates_to"}, - ] - - def create_issue(self, project_id: str, payload: dict[str, Any]) -> dict[str, Any]: - _ = project_id, payload - return {"id": "103", "key": "A-103", "url": "https://example.test/workitems/103"} - - -def test_verify_readiness_returns_ready_exit_code(monkeypatch) -> None: - runner = CliRunner() - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: _FakeVerifyAdapter()) - - result = runner.invoke( - backlog_app, - [ - "verify-readiness", - "--project-id", - "demo/project", - "--adapter", - "github", - "--target-items", - "100,101", - "--template", - "github_projects", - ], - ) - - assert result.exit_code == 0 - assert "READY" in result.stdout - - -class _FakeVerifyBlockedAdapter(_FakeVerifyAdapter): - def fetch_relationships(self, project_id: str) -> list[dict[str, Any]]: - _ = project_id - return [ - {"source_id": "100", "target_id": "101", "type": "parent_child"}, - {"source_id": "101", "target_id": "102", "type": "blocks"}, - ] - - -def test_verify_readiness_returns_blocked_exit_code(monkeypatch) -> None: - runner = CliRunner() - monkeypatch.setattr(AdapterRegistry, "get_adapter", lambda *_args, **_kwargs: _FakeVerifyBlockedAdapter()) - - result = runner.invoke( - backlog_app, - [ - "verify-readiness", - "--project-id", - "demo/project", - "--adapter", - "github", - "--target-items", - "100,101", - "--template", - "github_projects", - ], - ) - - assert result.exit_code == 1 - assert "BLOCKED" in result.stdout diff --git a/tests/integration/test_command_package_runtime_validation.py b/tests/integration/test_command_package_runtime_validation.py index c7fced07..b053ceb3 100644 --- a/tests/integration/test_command_package_runtime_validation.py +++ b/tests/integration/test_command_package_runtime_validation.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import shutil import subprocess import sys from pathlib import Path @@ -11,9 +10,21 @@ REPO_ROOT = Path(__file__).resolve().parents[2] SRC_ROOT = REPO_ROOT / "src" -MODULES_REPO = REPO_ROOT.parent / "specfact-cli-modules" + + +def _resolve_modules_repo() -> Path: + candidates = [ + REPO_ROOT.parent / "specfact-cli-modules", + REPO_ROOT.parents[2] / "specfact-cli-modules", + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] + + +MODULES_REPO = _resolve_modules_repo() REGISTRY_INDEX = MODULES_REPO / "registry" / "index.json" -BUILTIN_BACKLOG_CORE = REPO_ROOT / "modules" / "backlog-core" FORBIDDEN_OUTPUT = ( "Module compatibility check:", "Partially compliant modules:", @@ -32,7 +43,7 @@ def _subprocess_env(home_dir: Path) -> dict[str, str]: env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts) env["HOME"] = str(home_dir) env["SPECFACT_REPO_ROOT"] = str(REPO_ROOT) - env["SPECFACT_REGISTRY_INDEX_URL"] = str(REGISTRY_INDEX) + env["SPECFACT_REGISTRY_INDEX_URL"] = REGISTRY_INDEX.resolve().as_uri() env["SPECFACT_ALLOW_UNSIGNED"] = "1" env["SPECFACT_REGISTRY_DIR"] = str(home_dir / ".specfact-test-registry") return env @@ -80,15 +91,11 @@ def test_command_audit_help_cases_execute_cleanly_in_temp_home(tmp_path: Path) - assert not failures, "\n\n".join(failures) -def test_backlog_core_and_marketplace_overlap_is_silent_in_normal_output(tmp_path: Path) -> None: +def test_marketplace_backlog_bundle_registers_cleanly_without_core_overlap(tmp_path: Path) -> None: home_dir = tmp_path / "home" home_dir.mkdir(parents=True, exist_ok=True) env = _subprocess_env(home_dir) - target_root = home_dir / ".specfact" / "modules" - target_root.mkdir(parents=True, exist_ok=True) - shutil.copytree(BUILTIN_BACKLOG_CORE, target_root / "backlog-core") - install_result = _run_cli( env, "module", diff --git a/tests/unit/backlog/test_ai_refiner.py b/tests/unit/backlog/test_ai_refiner.py deleted file mode 100644 index 70369195..00000000 --- a/tests/unit/backlog/test_ai_refiner.py +++ /dev/null @@ -1,358 +0,0 @@ -""" -Unit tests for BacklogAIRefiner. - -Tests prompt generation and refinement validation. -""" - -from __future__ import annotations - -import pytest -from beartype import beartype - -from specfact_cli.backlog.ai_refiner import BacklogAIRefiner, RefinementResult -from specfact_cli.models.backlog_item import BacklogItem -from specfact_cli.templates.registry import BacklogTemplate - - -@pytest.fixture -def refiner() -> BacklogAIRefiner: - """Create BacklogAIRefiner instance.""" - return BacklogAIRefiner() - - -@pytest.fixture -def user_story_template() -> BacklogTemplate: - """Create user story template for testing.""" - return BacklogTemplate( - template_id="user_story_v1", - name="User Story", - description="Standard user story template", - required_sections=["As a", "I want", "So that", "Acceptance Criteria"], - optional_sections=["Notes"], - body_patterns={"as_a": "As a [^,]+ I want"}, - ) - - -@pytest.fixture -def arbitrary_backlog_item() -> BacklogItem: - """Create arbitrary backlog item (typical DevOps input).""" - return BacklogItem( - id="123", - provider="github", - url="https://github.com/test/repo/issues/123", - title="Need to add login feature", - body_markdown="""Hey team, -We need to add a login feature. Users are asking for it. -Can someone implement this? - -Thanks!""", - state="open", - ) - - -class TestBacklogAIRefiner: - """Test BacklogAIRefiner.""" - - @beartype - def test_generate_refinement_prompt( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Test generating refinement prompt for IDE AI copilot.""" - prompt = refiner.generate_refinement_prompt(arbitrary_backlog_item, user_story_template) - - assert isinstance(prompt, str) - assert len(prompt) > 0 - assert arbitrary_backlog_item.title in prompt - assert arbitrary_backlog_item.body_markdown in prompt - assert user_story_template.name in prompt - assert "As a" in prompt - assert "I want" in prompt - - @beartype - def test_generate_refinement_prompt_includes_comments_when_provided( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Prompt includes comment context so refinement sees evolving discussion.""" - prompt = refiner.generate_refinement_prompt( - arbitrary_backlog_item, - user_story_template, - comments=["First update from team", "Final clarification from PO"], - ) - assert "Comments" in prompt - assert "First update from team" in prompt - assert "Final clarification from PO" in prompt - - @beartype - def test_generate_refinement_prompt_mentions_no_comments_when_empty( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Prompt explicitly states that comments were checked but none exist.""" - prompt = refiner.generate_refinement_prompt(arbitrary_backlog_item, user_story_template, comments=[]) - assert "No comments found" in prompt - - @beartype - def test_generate_refinement_prompt_includes_expected_output_scaffold( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Prompt includes canonical output scaffold for Copilot consistency.""" - prompt = refiner.generate_refinement_prompt(arbitrary_backlog_item, user_story_template) - assert "Expected Output Scaffold" in prompt - assert "## Work Item Properties / Metadata" in prompt - assert "## Description" in prompt - assert "## Acceptance Criteria" in prompt - - @beartype - def test_generate_refinement_prompt_instructs_to_omit_unknown_metadata_fields( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Prompt instructs omitting unknown metadata fields instead of placeholders.""" - prompt = refiner.generate_refinement_prompt(arbitrary_backlog_item, user_story_template) - assert "omit unknown metadata fields" in prompt.lower() - assert "do not emit placeholders" in prompt.lower() - - @beartype - def test_generate_refinement_prompt_for_ado_excludes_story_points_from_required_sections( - self, refiner: BacklogAIRefiner - ) -> None: - """ADO prompt should not require Story Points as body section.""" - template = BacklogTemplate( - template_id="scrum_user_story_v1", - name="Scrum User Story", - description="", - required_sections=["As a", "I want", "So that", "Acceptance Criteria", "Story Points"], - optional_sections=["Area Path", "Iteration Path", "Notes"], - ) - item = BacklogItem( - id="100", - provider="ado", - url="https://dev.azure.com/org/project/_workitems/edit/100", - title="Story", - body_markdown="Body", - state="Active", - ) - prompt = refiner.generate_refinement_prompt(item, template) - required_section_block = prompt.split("Required Sections:")[1].split("Optional Sections:")[0] - assert "- Story Points" not in required_section_block - assert "- As a" in required_section_block - - @beartype - def test_validate_and_score_complete_refinement( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Test validating complete refinement (high confidence).""" - original_body = "Some original content" - refined_body = """## As a -user - -## I want -to log in - -## So that -I can access my account - -## Acceptance Criteria -- User can enter credentials -- User can click login button""" - - result = refiner.validate_and_score_refinement( - refined_body, original_body, user_story_template, arbitrary_backlog_item - ) - - assert isinstance(result, RefinementResult) - assert result.refined_body == refined_body - assert result.confidence >= 0.85 - assert result.has_todo_markers is False - assert result.has_notes_section is False - - @beartype - def test_validate_and_score_with_todo_markers( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Test validating refinement with TODO markers (medium confidence).""" - original_body = "Some original content" - refined_body = """## As a -user - -## I want -to log in - -## So that -[TODO: specify the benefit] - -## Acceptance Criteria -- User can enter credentials -- [TODO: add more criteria]""" - - result = refiner.validate_and_score_refinement( - refined_body, original_body, user_story_template, arbitrary_backlog_item - ) - - assert result.confidence < 0.85 - assert result.has_todo_markers is True - - @beartype - def test_validate_and_score_with_notes_section( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Test validating refinement with NOTES section (lower confidence).""" - original_body = "Some original content" - refined_body = """## As a -user - -## I want -to log in - -## So that -I can access my account - -## Acceptance Criteria -- User can enter credentials - -## NOTES -There's some ambiguity about the login method.""" - - result = refiner.validate_and_score_refinement( - refined_body, original_body, user_story_template, arbitrary_backlog_item - ) - - assert result.confidence < 0.85 - assert result.has_notes_section is True - - @beartype - def test_validate_missing_required_sections_raises( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Test that validation raises error for missing required sections.""" - original_body = "Some original content" - refined_body = "Incomplete refinement without required sections" - - with pytest.raises(ValueError, match="missing required sections"): - refiner.validate_and_score_refinement( - refined_body, original_body, user_story_template, arbitrary_backlog_item - ) - - @beartype - def test_validate_empty_refinement_raises( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Test that validation raises error for empty refinement.""" - original_body = "Some original content" - refined_body = "" - - with pytest.raises(ValueError, match="Refined body is empty"): - refiner.validate_and_score_refinement( - refined_body, original_body, user_story_template, arbitrary_backlog_item - ) - - @beartype - def test_validate_arbitrary_input_refinement( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Test validating refinement of arbitrary DevOps input.""" - # Simulate refined content from IDE AI copilot - refined_body = """## As a -user - -## I want -to log in to the system - -## So that -I can access my account and protected resources - -## Acceptance Criteria -- User can enter username and password -- User can click login button -- System validates credentials -- User is redirected to dashboard on success""" - - result = refiner.validate_and_score_refinement( - refined_body, arbitrary_backlog_item.body_markdown, user_story_template, arbitrary_backlog_item - ) - - assert result.confidence >= 0.85 - assert all(section in result.refined_body for section in ["As a", "I want", "So that", "Acceptance Criteria"]) - - @beartype - def test_validate_agile_fields_valid(self, refiner: BacklogAIRefiner) -> None: - """Test validating agile fields with valid values.""" - item = BacklogItem( - id="123", - provider="github", - url="https://github.com/test/repo/issues/123", - title="Test", - body_markdown="Test", - state="open", - story_points=8, - business_value=50, - priority=2, - value_points=6, - ) - - errors = refiner._validate_agile_fields(item) - assert errors == [] - - @beartype - def test_validate_agile_fields_invalid_story_points(self, refiner: BacklogAIRefiner) -> None: - """Test validating agile fields with invalid story_points.""" - item = BacklogItem( - id="123", - provider="github", - url="https://github.com/test/repo/issues/123", - title="Test", - body_markdown="Test", - state="open", - story_points=150, # Out of range - ) - - errors = refiner._validate_agile_fields(item) - assert len(errors) > 0 - assert any("story_points" in error and "0-100" in error for error in errors) - - @beartype - def test_validate_agile_fields_invalid_priority(self, refiner: BacklogAIRefiner) -> None: - """Test validating agile fields with invalid priority.""" - item = BacklogItem( - id="123", - provider="github", - url="https://github.com/test/repo/issues/123", - title="Test", - body_markdown="Test", - state="open", - priority=10, # Out of range - ) - - errors = refiner._validate_agile_fields(item) - assert len(errors) > 0 - assert any("priority" in error and "1-4" in error for error in errors) - - @beartype - def test_validate_and_score_with_invalid_fields_raises( - self, refiner: BacklogAIRefiner, arbitrary_backlog_item: BacklogItem, user_story_template: BacklogTemplate - ) -> None: - """Test that validation raises error for invalid agile fields.""" - # Create item with invalid story_points - invalid_item = BacklogItem( - id="123", - provider="github", - url="https://github.com/test/repo/issues/123", - title="Test", - body_markdown="Test", - state="open", - story_points=150, # Out of range - ) - - original_body = "Some original content" - refined_body = """## As a -user - -## I want -to log in - -## So that -I can access my account - -## Acceptance Criteria -- User can enter credentials""" - - with pytest.raises(ValueError, match="Field validation errors"): - refiner.validate_and_score_refinement(refined_body, original_body, user_story_template, invalid_item) diff --git a/tests/unit/backlog/test_analyzers.py b/tests/unit/backlog/test_analyzers.py deleted file mode 100644 index 447d814a..00000000 --- a/tests/unit/backlog/test_analyzers.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Unit tests for DependencyAnalyzer.""" - -from __future__ import annotations - -import json -import sys -import time -from pathlib import Path - - -# ruff: noqa: E402 - - -def _add_backlog_core_to_path() -> None: - repo_root = Path(__file__).resolve().parents[3] - module_src = repo_root / "modules" / "backlog-core" / "src" - sys.path.insert(0, str(module_src)) - - -_add_backlog_core_to_path() - -from backlog_core.analyzers.dependency import DependencyAnalyzer -from backlog_core.graph.builder import BacklogGraphBuilder -from backlog_core.graph.models import BacklogGraph, BacklogItem, Dependency, DependencyType, ItemType - - -def _load_fixture(name: str) -> dict[str, object]: - fixture = Path(__file__).resolve().parent / "fixtures" / name - return json.loads(fixture.read_text(encoding="utf-8")) - - -def _build_linear_graph() -> BacklogGraph: - items = { - "1": BacklogItem(id="1", key="A", title="A", type=ItemType.FEATURE), - "2": BacklogItem(id="2", key="B", title="B", type=ItemType.STORY), - "3": BacklogItem(id="3", key="C", title="C", type=ItemType.TASK), - } - deps = [ - Dependency(source_id="1", target_id="2", type=DependencyType.BLOCKS), - Dependency(source_id="2", target_id="3", type=DependencyType.BLOCKS), - ] - return BacklogGraph(items=items, dependencies=deps, provider="github", project_key="repo") - - -def _build_large_chain_graph(node_count: int = 1200) -> BacklogGraph: - items = { - str(i): BacklogItem(id=str(i), key=f"K-{i}", title=f"Item {i}", type=ItemType.TASK) for i in range(node_count) - } - deps = [ - Dependency(source_id=str(i), target_id=str(i + 1), type=DependencyType.BLOCKS) for i in range(node_count - 1) - ] - return BacklogGraph(items=items, dependencies=deps, provider="github", project_key="repo") - - -def test_compute_transitive_closure() -> None: - analyzer = DependencyAnalyzer(_build_linear_graph()) - - closure = analyzer.compute_transitive_closure() - - assert closure["1"] == ["2", "3"] - - -def test_detect_cycles_with_fixture() -> None: - sample = _load_fixture("cycles_fixture.json") - graph = ( - BacklogGraphBuilder("github", "github_projects") - .add_items(sample["items"]) - .add_dependencies(sample["relationships"]) - .build() - ) # type: ignore[index] - - analyzer = DependencyAnalyzer(graph) - cycles = analyzer.detect_cycles() - - assert cycles - - -def test_critical_path_prefers_longest_chain() -> None: - analyzer = DependencyAnalyzer(_build_linear_graph()) - - assert analyzer.critical_path() == ["1", "2", "3"] - - -def test_critical_path_handles_large_graph_under_one_second() -> None: - analyzer = DependencyAnalyzer(_build_large_chain_graph()) - - started_at = time.perf_counter() - path = analyzer.critical_path() - elapsed = time.perf_counter() - started_at - - assert len(path) == 1200 - assert elapsed < 1.0 - - -def test_impact_analysis_reports_dependents_and_blockers() -> None: - analyzer = DependencyAnalyzer(_build_linear_graph()) - - impact = analyzer.impact_analysis("2") - - assert impact["direct_dependents"] == ["1"] - assert impact["transitive_dependents"] == ["1"] - assert impact["blockers"] == ["3"] - assert impact["estimated_impact_count"] == 1 - - -def test_coverage_analysis_includes_cycle_and_orphan_counts() -> None: - graph = _build_linear_graph() - graph.orphans = ["1"] - analyzer = DependencyAnalyzer(graph) - - metrics = analyzer.coverage_analysis() - - assert metrics["total_items"] == 3 - assert metrics["properly_typed"] == 3 - assert metrics["with_dependencies"] == 3 - assert metrics["orphan_count"] == 1 - assert metrics["cycle_count"] == 0 diff --git a/tests/unit/backlog/test_backlog_adapter.py b/tests/unit/backlog/test_backlog_adapter.py deleted file mode 100644 index 7cc23e8c..00000000 --- a/tests/unit/backlog/test_backlog_adapter.py +++ /dev/null @@ -1,259 +0,0 @@ -""" -Unit tests for BacklogAdapter interface. - -Tests the abstract BacklogAdapter interface and its contract requirements. -""" - -from __future__ import annotations - -from beartype import beartype - -from specfact_cli.backlog.adapters.base import BacklogAdapter -from specfact_cli.backlog.filters import BacklogFilters -from specfact_cli.models.backlog_item import BacklogItem - - -class MockBacklogAdapter(BacklogAdapter): - """Mock implementation of BacklogAdapter for testing.""" - - def __init__(self, name: str = "mock", supports_format_type: str = "markdown") -> None: - """Initialize mock adapter.""" - self._name = name - self._supports_format_type = supports_format_type - self._items: list[BacklogItem] = [] - - @beartype - def name(self) -> str: - """Get adapter name.""" - return self._name - - @beartype - def supports_format(self, format_type: str) -> bool: - """Check if adapter supports format.""" - return format_type.lower() == self._supports_format_type.lower() - - @beartype - def fetch_backlog_items(self, filters: BacklogFilters) -> list[BacklogItem]: - """Fetch backlog items.""" - filtered = self._items.copy() - - if filters.state: - filtered = [item for item in filtered if item.state.lower() == filters.state.lower()] - - if filters.assignee: - filtered = [ - item - for item in filtered - if any(assignee.lower() == filters.assignee.lower() for assignee in item.assignees) - ] - - if filters.labels: - filtered = [item for item in filtered if any(label in item.tags for label in filters.labels)] - - return filtered - - @beartype - def update_backlog_item(self, item: BacklogItem, update_fields: list[str] | None = None) -> BacklogItem: - """Update backlog item.""" - # Find and update item in mock storage - for i, existing_item in enumerate(self._items): - if existing_item.id == item.id and existing_item.provider == item.provider: - if update_fields is None: - self._items[i] = item - else: - # Update only specified fields - updated_dict = existing_item.model_dump() - for field in update_fields: - if hasattr(item, field): - updated_dict[field] = getattr(item, field) - self._items[i] = BacklogItem(**updated_dict) - return self._items[i] - - # If not found, add it - self._items.append(item) - return item - - def add_test_item(self, item: BacklogItem) -> None: - """Add test item to mock storage.""" - self._items.append(item) - - -class TestBacklogAdapter: - """Test BacklogAdapter interface.""" - - @beartype - def test_adapter_name(self) -> None: - """Test adapter name method.""" - adapter = MockBacklogAdapter(name="test_adapter") - assert adapter.name() == "test_adapter" - - @beartype - def test_supports_format_true(self) -> None: - """Test supports_format returns True for supported format.""" - adapter = MockBacklogAdapter(supports_format_type="markdown") - assert adapter.supports_format("markdown") is True - assert adapter.supports_format("MARKDOWN") is True # Case insensitive - - @beartype - def test_supports_format_false(self) -> None: - """Test supports_format returns False for unsupported format.""" - adapter = MockBacklogAdapter(supports_format_type="markdown") - assert adapter.supports_format("yaml") is False - assert adapter.supports_format("json") is False - - @beartype - def test_fetch_backlog_items_empty(self) -> None: - """Test fetching items when adapter has no items.""" - adapter = MockBacklogAdapter() - filters = BacklogFilters() - items = adapter.fetch_backlog_items(filters) - assert items == [] - - @beartype - def test_fetch_backlog_items_with_state_filter(self) -> None: - """Test fetching items with state filter.""" - adapter = MockBacklogAdapter() - item1 = BacklogItem(id="1", provider="test", url="", title="Item 1", state="open") - item2 = BacklogItem(id="2", provider="test", url="", title="Item 2", state="closed") - adapter.add_test_item(item1) - adapter.add_test_item(item2) - - filters = BacklogFilters(state="open") - items = adapter.fetch_backlog_items(filters) - assert len(items) == 1 - assert items[0].id == "1" - assert items[0].state == "open" - - @beartype - def test_fetch_backlog_items_with_assignee_filter(self) -> None: - """Test fetching items with assignee filter.""" - adapter = MockBacklogAdapter() - item1 = BacklogItem(id="1", provider="test", url="", title="Item 1", state="open", assignees=["alice"]) - item2 = BacklogItem(id="2", provider="test", url="", title="Item 2", state="open", assignees=["bob"]) - adapter.add_test_item(item1) - adapter.add_test_item(item2) - - filters = BacklogFilters(assignee="alice") - items = adapter.fetch_backlog_items(filters) - assert len(items) == 1 - assert items[0].id == "1" - assert "alice" in items[0].assignees - - @beartype - def test_fetch_backlog_items_with_labels_filter(self) -> None: - """Test fetching items with labels filter.""" - adapter = MockBacklogAdapter() - item1 = BacklogItem(id="1", provider="test", url="", title="Item 1", state="open", tags=["feature"]) - item2 = BacklogItem(id="2", provider="test", url="", title="Item 2", state="open", tags=["bug"]) - adapter.add_test_item(item1) - adapter.add_test_item(item2) - - filters = BacklogFilters(labels=["feature"]) - items = adapter.fetch_backlog_items(filters) - assert len(items) == 1 - assert items[0].id == "1" - assert "feature" in items[0].tags - - @beartype - def test_fetch_backlog_items_multiple_filters(self) -> None: - """Test fetching items with multiple filters.""" - adapter = MockBacklogAdapter() - item1 = BacklogItem( - id="1", provider="test", url="", title="Item 1", state="open", assignees=["alice"], tags=["feature"] - ) - item2 = BacklogItem( - id="2", provider="test", url="", title="Item 2", state="open", assignees=["bob"], tags=["feature"] - ) - adapter.add_test_item(item1) - adapter.add_test_item(item2) - - filters = BacklogFilters(state="open", assignee="alice", labels=["feature"]) - items = adapter.fetch_backlog_items(filters) - assert len(items) == 1 - assert items[0].id == "1" - - @beartype - def test_update_backlog_item_all_fields(self) -> None: - """Test updating all fields of a backlog item.""" - adapter = MockBacklogAdapter() - original_item = BacklogItem( - id="1", provider="test", url="", title="Original Title", body_markdown="Original body", state="open" - ) - adapter.add_test_item(original_item) - - updated_item = BacklogItem( - id="1", provider="test", url="", title="Updated Title", body_markdown="Updated body", state="closed" - ) - result = adapter.update_backlog_item(updated_item, update_fields=None) - - assert result.title == "Updated Title" - assert result.body_markdown == "Updated body" - assert result.state == "closed" - assert result.id == "1" - assert result.provider == "test" - - @beartype - def test_update_backlog_item_selective_fields(self) -> None: - """Test updating only selected fields.""" - adapter = MockBacklogAdapter() - original_item = BacklogItem( - id="1", provider="test", url="", title="Original Title", body_markdown="Original body", state="open" - ) - adapter.add_test_item(original_item) - - updated_item = BacklogItem( - id="1", provider="test", url="", title="Updated Title", body_markdown="Updated body", state="closed" - ) - result = adapter.update_backlog_item(updated_item, update_fields=["title"]) - - assert result.title == "Updated Title" - assert result.body_markdown == "Original body" # Not updated - assert result.state == "open" # Not updated - - @beartype - def test_update_backlog_item_new_item(self) -> None: - """Test updating a non-existent item (creates new).""" - adapter = MockBacklogAdapter() - new_item = BacklogItem(id="1", provider="test", url="", title="New Item", state="open") - result = adapter.update_backlog_item(new_item) - - assert result.id == "1" - assert len(adapter._items) == 1 - - @beartype - def test_validate_round_trip_success(self) -> None: - """Test validate_round_trip returns True for preserved content.""" - adapter = MockBacklogAdapter() - original = BacklogItem( - id="1", provider="test", url="http://test.com/1", title="Test", body_markdown="Body", state="open" - ) - updated = BacklogItem( - id="1", provider="test", url="http://test.com/1", title="Test", body_markdown="Body", state="open" - ) - - assert adapter.validate_round_trip(original, updated) is True - - @beartype - def test_validate_round_trip_failure_id_mismatch(self) -> None: - """Test validate_round_trip returns False for id mismatch.""" - adapter = MockBacklogAdapter() - original = BacklogItem(id="1", provider="test", url="", title="Test", state="open") - updated = BacklogItem(id="2", provider="test", url="", title="Test", state="open") - - assert adapter.validate_round_trip(original, updated) is False - - @beartype - def test_validate_round_trip_failure_title_mismatch(self) -> None: - """Test validate_round_trip returns False for title mismatch.""" - adapter = MockBacklogAdapter() - original = BacklogItem(id="1", provider="test", url="", title="Original", state="open") - updated = BacklogItem(id="1", provider="test", url="", title="Updated", state="open") - - assert adapter.validate_round_trip(original, updated) is False - - @beartype - def test_create_backlog_item_from_spec_default(self) -> None: - """Test create_backlog_item_from_spec returns None by default.""" - adapter = MockBacklogAdapter() - result = adapter.create_backlog_item_from_spec() - assert result is None diff --git a/tests/unit/backlog/test_backlog_format.py b/tests/unit/backlog/test_backlog_format.py deleted file mode 100644 index 50f3b3eb..00000000 --- a/tests/unit/backlog/test_backlog_format.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Unit tests for BacklogFormat abstraction. - -Tests the abstract BacklogFormat interface and round-trip preservation. -""" - -from __future__ import annotations - -from beartype import beartype - -from specfact_cli.backlog.formats.base import BacklogFormat -from specfact_cli.models.backlog_item import BacklogItem - - -class MockBacklogFormat(BacklogFormat): - """Mock implementation of BacklogFormat for testing.""" - - def __init__(self, format_type: str = "mock") -> None: - """Initialize mock format.""" - self._format_type = format_type - - @property - @beartype - def format_type(self) -> str: - """Get format type.""" - return self._format_type - - @beartype - def serialize(self, item: BacklogItem) -> str: - """Serialize item to string.""" - return f"{item.id}|{item.title}|{item.body_markdown}|{item.state}" - - @beartype - def deserialize(self, raw: str) -> BacklogItem: - """Deserialize string to item.""" - parts = raw.split("|") - return BacklogItem(id=parts[0], provider="test", url="", title=parts[1], body_markdown=parts[2], state=parts[3]) - - -class TestBacklogFormat: - """Test BacklogFormat abstraction.""" - - @beartype - def test_format_type_property(self) -> None: - """Test format_type property.""" - formatter = MockBacklogFormat(format_type="test_format") - assert formatter.format_type == "test_format" - - @beartype - def test_serialize(self) -> None: - """Test serialization.""" - formatter = MockBacklogFormat() - item = BacklogItem(id="1", provider="test", url="", title="Test", body_markdown="Body", state="open") - serialized = formatter.serialize(item) - assert serialized == "1|Test|Body|open" - - @beartype - def test_deserialize(self) -> None: - """Test deserialization.""" - formatter = MockBacklogFormat() - raw = "1|Test|Body|open" - item = formatter.deserialize(raw) - assert item.id == "1" - assert item.title == "Test" - assert item.body_markdown == "Body" - assert item.state == "open" - - @beartype - def test_roundtrip_preserves_content(self) -> None: - """Test round-trip preserves essential content (id, title, body, state).""" - formatter = MockBacklogFormat() - original = BacklogItem( - id="1", - provider="test", - url="http://test.com/1", - title="Test Item", - body_markdown="Test body", - state="open", - ) - # Mock format only preserves id, title, body_markdown, state in serialization - assert formatter.roundtrip_preserves_content(original) is True - - @beartype - def test_roundtrip_preserves_essential_fields(self) -> None: - """Test round-trip preserves essential fields (id, title, body, state).""" - formatter = MockBacklogFormat() - original = BacklogItem( - id="123", - provider="test", - url="http://test.com/123", - title="Complex Item", - body_markdown="Complex body with\nmultiple lines", - state="closed", - ) - - serialized = formatter.serialize(original) - deserialized = formatter.deserialize(serialized) - - # Mock format preserves id, title, body_markdown, state - assert deserialized.id == original.id - assert deserialized.title == original.title - assert deserialized.body_markdown == original.body_markdown - assert deserialized.state == original.state diff --git a/tests/unit/backlog/test_builders.py b/tests/unit/backlog/test_builders.py deleted file mode 100644 index 6bce6386..00000000 --- a/tests/unit/backlog/test_builders.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Unit tests for BacklogGraphBuilder.""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - - -# ruff: noqa: E402 - - -def _add_backlog_core_to_path() -> None: - repo_root = Path(__file__).resolve().parents[3] - module_src = repo_root / "modules" / "backlog-core" / "src" - sys.path.insert(0, str(module_src)) - - -_add_backlog_core_to_path() - -from backlog_core.graph.builder import BacklogGraphBuilder -from backlog_core.graph.models import DependencyType, ItemType - - -def _load_fixture(name: str) -> dict[str, object]: - fixture = Path(__file__).resolve().parent / "fixtures" / name - return json.loads(fixture.read_text(encoding="utf-8")) - - -def test_builder_maps_ado_types_and_relationships() -> None: - sample = _load_fixture("ado_sample_graph.json") - builder = BacklogGraphBuilder(provider="ado", template_name="ado_scrum") - - graph = builder.add_items(sample["items"]).add_dependencies(sample["relationships"]).build() # type: ignore[index] - - assert graph.items["100"].effective_type() == ItemType.EPIC - assert graph.items["101"].status == "in_progress" - assert graph.dependencies[0].type == DependencyType.PARENT_CHILD - - -def test_builder_detects_cycle_and_transitive_closure() -> None: - sample = _load_fixture("cycles_fixture.json") - builder = BacklogGraphBuilder(provider="github", template_name="github_projects") - - graph = builder.add_items(sample["items"]).add_dependencies(sample["relationships"]).build() # type: ignore[index] - - assert "a" in graph.transitive_closure - assert graph.cycles_detected - - -def test_builder_applies_custom_status_override() -> None: - sample = _load_fixture("github_sample_graph.json") - builder = BacklogGraphBuilder( - provider="github", - template_name="github_projects", - custom_config={"status_mapping": {"in progress": "doing"}}, - ) - - graph = builder.add_items(sample["items"]).add_dependencies(sample["relationships"]).build() # type: ignore[index] - - assert graph.items["2"].status == "doing" - - -def test_builder_marks_orphans_without_parents_or_inbound_dependencies() -> None: - sample = _load_fixture("github_sample_graph.json") - sample["relationships"] = [] - - builder = BacklogGraphBuilder(provider="github", template_name="github_projects") - graph = builder.add_items(sample["items"]).add_dependencies(sample["relationships"]).build() # type: ignore[index] - - assert sorted(graph.orphans) == ["1", "2"] - - -def test_builder_loads_backlog_config_from_spec_yaml(tmp_path: Path, monkeypatch) -> None: - spec_dir = tmp_path / ".specfact" - spec_dir.mkdir(parents=True) - (spec_dir / "spec.yaml").write_text( - "backlog_config:\n dependencies:\n status_mapping:\n todo: planned\n", - encoding="utf-8", - ) - monkeypatch.chdir(tmp_path) - - sample = _load_fixture("github_sample_graph.json") - builder = BacklogGraphBuilder(provider="github", template_name="github_projects") - graph = builder.add_items(sample["items"]).add_dependencies(sample["relationships"]).build() # type: ignore[index] - - assert graph.items["1"].status == "planned" - - -def test_builder_custom_config_overrides_spec_and_metadata() -> None: - sample = _load_fixture("github_sample_graph.json") - builder = BacklogGraphBuilder( - provider="github", - template_name="github_projects", - custom_config={ - "project_bundle_metadata": { - "backlog_core": { - "backlog_config": { - "dependencies": { - "status_mapping": {"in progress": "meta-doing"}, - } - } - } - }, - "status_mapping": {"in progress": "custom-doing"}, - }, - ) - - graph = builder.add_items(sample["items"]).add_dependencies(sample["relationships"]).build() # type: ignore[index] - assert graph.items["2"].status == "custom-doing" - - -def test_builder_reads_backlog_config_from_metadata_extensions() -> None: - sample = _load_fixture("github_sample_graph.json") - builder = BacklogGraphBuilder( - provider="github", - template_name="github_projects", - custom_config={ - "project_bundle_metadata": { - "extensions": { - "backlog_core": { - "backlog_config": { - "dependencies": { - "status_mapping": {"in progress": "extension-doing"}, - } - } - } - } - } - }, - ) - - graph = builder.add_items(sample["items"]).add_dependencies(sample["relationships"]).build() # type: ignore[index] - assert graph.items["2"].status == "extension-doing" diff --git a/tests/unit/backlog/test_format_detector.py b/tests/unit/backlog/test_format_detector.py deleted file mode 100644 index a44efb76..00000000 --- a/tests/unit/backlog/test_format_detector.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Unit tests for format detection. - -Tests automatic format detection heuristics. -""" - -from __future__ import annotations - -from beartype import beartype - -from specfact_cli.backlog.format_detector import detect_format - - -class TestFormatDetector: - """Test format detection.""" - - @beartype - def test_detect_json_object(self) -> None: - """Test detecting JSON object format.""" - raw = '{"key": "value"}' - assert detect_format(raw) == "json" - - @beartype - def test_detect_json_array(self) -> None: - """Test detecting JSON array format.""" - raw = '[{"key": "value"}]' - assert detect_format(raw) == "json" - - @beartype - def test_detect_yaml_frontmatter(self) -> None: - """Test detecting YAML with frontmatter.""" - raw = """--- -key: value ---- -Content""" - assert detect_format(raw) == "yaml" - - @beartype - def test_detect_yaml_key_value(self) -> None: - """Test detecting YAML with key:value pattern.""" - raw = "key: value\nother: data" - assert detect_format(raw) == "yaml" - - @beartype - def test_detect_markdown_default(self) -> None: - """Test defaulting to markdown for other cases.""" - raw = "# Markdown heading\n\nSome content here." - assert detect_format(raw) == "markdown" - - @beartype - def test_detect_markdown_with_hash_comment(self) -> None: - """Test markdown with hash comment (not YAML).""" - raw = "# This is a comment\n\nContent here." - assert detect_format(raw) == "markdown" - - @beartype - def test_detect_empty_string(self) -> None: - """Test detecting format of empty string (defaults to markdown).""" - raw = "" - assert detect_format(raw) == "markdown" - - @beartype - def test_detect_whitespace_only(self) -> None: - """Test detecting format of whitespace-only string.""" - raw = " \n\t " - assert detect_format(raw) == "markdown" diff --git a/tests/unit/backlog/test_graph_models.py b/tests/unit/backlog/test_graph_models.py deleted file mode 100644 index b6532737..00000000 --- a/tests/unit/backlog/test_graph_models.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Unit tests for backlog-core graph models.""" -# ruff: noqa: E402 - -from __future__ import annotations - -import sys -from pathlib import Path - - -def _add_backlog_core_to_path() -> None: - repo_root = Path(__file__).resolve().parents[3] - module_src = repo_root / "modules" / "backlog-core" / "src" - sys.path.insert(0, str(module_src)) - - -_add_backlog_core_to_path() - -from backlog_core.graph.models import BacklogGraph, BacklogItem, Dependency, DependencyType, ItemType - - -def test_backlog_item_effective_type_uses_inferred_when_confident() -> None: - item = BacklogItem( - id="1", - key="ABC-1", - title="Example", - type=ItemType.STORY, - inferred_type=ItemType.FEATURE, - confidence=0.9, - ) - - assert item.effective_type() == ItemType.FEATURE - - -def test_backlog_item_effective_type_falls_back_to_declared_type() -> None: - item = BacklogItem( - id="1", - key="ABC-1", - title="Example", - type=ItemType.STORY, - inferred_type=ItemType.FEATURE, - confidence=0.2, - ) - - assert item.effective_type() == ItemType.STORY - - -def test_dependency_model_accepts_normalized_types() -> None: - dep = Dependency(source_id="1", target_id="2", type=DependencyType.BLOCKS) - - assert dep.type == DependencyType.BLOCKS - - -def test_backlog_graph_json_roundtrip() -> None: - item = BacklogItem(id="1", key="ABC-1", title="Example", type=ItemType.TASK) - dep = Dependency(source_id="1", target_id="2", type=DependencyType.RELATES_TO) - graph = BacklogGraph( - items={"1": item}, - dependencies=[dep], - provider="github", - project_key="nold-ai/specfact-cli", - transitive_closure={"1": ["2"]}, - cycles_detected=[], - orphans=["1"], - ) - - payload = graph.to_json() - restored = BacklogGraph.from_json(payload) - - assert restored.provider == "github" - assert restored.project_key == "nold-ai/specfact-cli" - assert restored.items["1"].title == "Example" - assert restored.dependencies[0].type == DependencyType.RELATES_TO - assert restored.transitive_closure["1"] == ["2"] - assert restored.orphans == ["1"] diff --git a/tests/unit/backlog/test_local_yaml_adapter.py b/tests/unit/backlog/test_local_yaml_adapter.py deleted file mode 100644 index 217e4a08..00000000 --- a/tests/unit/backlog/test_local_yaml_adapter.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -Unit tests for LocalYAMLBacklogAdapter. - -Tests the local YAML adapter implementation. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from beartype import beartype - -from specfact_cli.backlog.adapters.local_yaml_adapter import LocalYAMLBacklogAdapter -from specfact_cli.backlog.filters import BacklogFilters -from specfact_cli.models.backlog_item import BacklogItem - - -@pytest.fixture -def temp_backlog_file(tmp_path: Path) -> Path: - """Create temporary backlog YAML file.""" - backlog_file = tmp_path / ".specfact" / "backlog.yaml" - backlog_file.parent.mkdir(parents=True, exist_ok=True) - return backlog_file - - -@pytest.fixture -def sample_backlog_items() -> list[BacklogItem]: - """Create sample backlog items for testing.""" - return [ - BacklogItem( - id="1", - provider="local_yaml", - url="", - title="Open feature", - body_markdown="Feature description", - state="open", - assignees=["alice"], - tags=["feature"], - ), - BacklogItem( - id="2", - provider="local_yaml", - url="", - title="Closed bug", - body_markdown="Bug description", - state="closed", - assignees=["bob"], - tags=["bug"], - ), - BacklogItem( - id="3", - provider="local_yaml", - url="", - title="Open task", - body_markdown="Task description", - state="open", - assignees=["alice"], - tags=["task"], - ), - ] - - -class TestLocalYAMLBacklogAdapter: - """Test LocalYAMLBacklogAdapter.""" - - @beartype - def test_adapter_name(self) -> None: - """Test adapter name.""" - adapter = LocalYAMLBacklogAdapter() - assert adapter.name() == "local_yaml" - - @beartype - def test_supports_format_yaml(self) -> None: - """Test supports_format for YAML.""" - adapter = LocalYAMLBacklogAdapter() - assert adapter.supports_format("yaml") is True - assert adapter.supports_format("YAML") is True - - @beartype - def test_supports_format_other(self) -> None: - """Test supports_format for other formats.""" - adapter = LocalYAMLBacklogAdapter() - assert adapter.supports_format("markdown") is False - assert adapter.supports_format("json") is False - - @beartype - def test_fetch_backlog_items_empty_file(self, temp_backlog_file: Path) -> None: - """Test fetching from empty file.""" - adapter = LocalYAMLBacklogAdapter(backlog_file=temp_backlog_file) - filters = BacklogFilters() - items = adapter.fetch_backlog_items(filters) - assert items == [] - - @beartype - def test_fetch_backlog_items_with_state_filter( - self, temp_backlog_file: Path, sample_backlog_items: list[BacklogItem] - ) -> None: - """Test fetching with state filter.""" - from specfact_cli.utils.yaml_utils import dump_yaml - - # Create backlog file with items - data = {"items": [item.model_dump() for item in sample_backlog_items]} - dump_yaml(data, temp_backlog_file) - - adapter = LocalYAMLBacklogAdapter(backlog_file=temp_backlog_file) - filters = BacklogFilters(state="open") - items = adapter.fetch_backlog_items(filters) - - assert len(items) == 2 - assert all(item.state == "open" for item in items) - - @beartype - def test_fetch_backlog_items_with_assignee_filter( - self, temp_backlog_file: Path, sample_backlog_items: list[BacklogItem] - ) -> None: - """Test fetching with assignee filter.""" - from specfact_cli.utils.yaml_utils import dump_yaml - - data = {"items": [item.model_dump() for item in sample_backlog_items]} - dump_yaml(data, temp_backlog_file) - - adapter = LocalYAMLBacklogAdapter(backlog_file=temp_backlog_file) - filters = BacklogFilters(assignee="alice") - items = adapter.fetch_backlog_items(filters) - - assert len(items) == 2 - assert all("alice" in item.assignees for item in items) - - @beartype - def test_fetch_backlog_items_with_labels_filter( - self, temp_backlog_file: Path, sample_backlog_items: list[BacklogItem] - ) -> None: - """Test fetching with labels filter.""" - from specfact_cli.utils.yaml_utils import dump_yaml - - data = {"items": [item.model_dump() for item in sample_backlog_items]} - dump_yaml(data, temp_backlog_file) - - adapter = LocalYAMLBacklogAdapter(backlog_file=temp_backlog_file) - filters = BacklogFilters(labels=["feature"]) - items = adapter.fetch_backlog_items(filters) - - assert len(items) == 1 - assert items[0].id == "1" - assert "feature" in items[0].tags - - @beartype - def test_fetch_backlog_items_with_search_filter( - self, temp_backlog_file: Path, sample_backlog_items: list[BacklogItem] - ) -> None: - """Test fetching with search filter.""" - from specfact_cli.utils.yaml_utils import dump_yaml - - data = {"items": [item.model_dump() for item in sample_backlog_items]} - dump_yaml(data, temp_backlog_file) - - adapter = LocalYAMLBacklogAdapter(backlog_file=temp_backlog_file) - filters = BacklogFilters(search="bug") - items = adapter.fetch_backlog_items(filters) - - assert len(items) == 1 - assert "bug" in items[0].title.lower() or "bug" in items[0].body_markdown.lower() - - @beartype - def test_update_backlog_item_new_item(self, temp_backlog_file: Path) -> None: - """Test updating a new item (creates it).""" - adapter = LocalYAMLBacklogAdapter(backlog_file=temp_backlog_file) - new_item = BacklogItem(id="1", provider="local_yaml", url="", title="New Item", state="open") - - result = adapter.update_backlog_item(new_item) - - assert result.id == "1" - assert temp_backlog_file.exists() - - # Verify item was saved - items = adapter.fetch_backlog_items(BacklogFilters()) - assert len(items) == 1 - assert items[0].id == "1" - - @beartype - def test_update_backlog_item_existing_item( - self, temp_backlog_file: Path, sample_backlog_items: list[BacklogItem] - ) -> None: - """Test updating an existing item.""" - from specfact_cli.utils.yaml_utils import dump_yaml - - data = {"items": [item.model_dump() for item in sample_backlog_items]} - dump_yaml(data, temp_backlog_file) - - adapter = LocalYAMLBacklogAdapter(backlog_file=temp_backlog_file) - updated_item = BacklogItem( - id="1", - provider="local_yaml", - url="", - title="Updated Title", - body_markdown="Updated body", - state="closed", - ) - - result = adapter.update_backlog_item(updated_item, update_fields=None) - - assert result.title == "Updated Title" - assert result.body_markdown == "Updated body" - assert result.state == "closed" - - @beartype - def test_update_backlog_item_selective_fields( - self, temp_backlog_file: Path, sample_backlog_items: list[BacklogItem] - ) -> None: - """Test updating only selected fields.""" - from specfact_cli.utils.yaml_utils import dump_yaml - - data = {"items": [item.model_dump() for item in sample_backlog_items]} - dump_yaml(data, temp_backlog_file) - - adapter = LocalYAMLBacklogAdapter(backlog_file=temp_backlog_file) - updated_item = BacklogItem( - id="1", - provider="local_yaml", - url="", - title="Updated Title", - body_markdown="Original body", - state="open", - ) - - result = adapter.update_backlog_item(updated_item, update_fields=["title"]) - - assert result.title == "Updated Title" - # Other fields should remain unchanged (from original item in file) diff --git a/tests/unit/backlog/test_markdown_format.py b/tests/unit/backlog/test_markdown_format.py deleted file mode 100644 index 66875e97..00000000 --- a/tests/unit/backlog/test_markdown_format.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -Unit tests for MarkdownFormat implementation. - -Tests Markdown serialization and deserialization with optional YAML frontmatter. -""" - -from __future__ import annotations - -from beartype import beartype - -from specfact_cli.backlog.formats.markdown_format import MarkdownFormat -from specfact_cli.models.backlog_item import BacklogItem - - -class TestMarkdownFormat: - """Test MarkdownFormat implementation.""" - - @beartype - def test_format_type(self) -> None: - """Test format_type property.""" - formatter = MarkdownFormat() - assert formatter.format_type == "markdown" - - @beartype - def test_serialize_plain_markdown(self) -> None: - """Test serializing plain markdown without provider_fields.""" - formatter = MarkdownFormat() - item = BacklogItem(id="1", provider="test", url="", title="Test", body_markdown="Plain markdown", state="open") - serialized = formatter.serialize(item) - assert serialized == "Plain markdown" - - @beartype - def test_serialize_with_provider_fields(self) -> None: - """Test serializing markdown with YAML frontmatter for provider_fields.""" - formatter = MarkdownFormat() - item = BacklogItem( - id="1", - provider="test", - url="", - title="Test", - body_markdown="Markdown body", - state="open", - provider_fields={"number": "123", "html_url": "http://test.com/123"}, - ) - serialized = formatter.serialize(item) - assert "---" in serialized - assert "number: 123" in serialized - assert "html_url: http://test.com/123" in serialized - assert "Markdown body" in serialized - - @beartype - def test_deserialize_plain_markdown(self) -> None: - """Test deserializing plain markdown.""" - formatter = MarkdownFormat() - raw = "Plain markdown content" - item = formatter.deserialize(raw) - assert item.body_markdown == "Plain markdown content" - # Note: deserialize creates placeholder item since markdown doesn't contain full item metadata - assert item.id == "placeholder" - assert item.provider == "unknown" - - @beartype - def test_deserialize_with_frontmatter(self) -> None: - """Test deserializing markdown with YAML frontmatter.""" - formatter = MarkdownFormat() - raw = """--- -number: 123 -html_url: http://test.com/123 ---- -Markdown body content""" - item = formatter.deserialize(raw) - assert item.body_markdown == "Markdown body content" - assert item.provider_fields is not None - # Note: Simple YAML parser converts "123" to int(123) if it's a digit - assert item.provider_fields.get("number") in (123, "123") # Accept both - assert item.provider_fields.get("html_url") == "http://test.com/123" - - @beartype - def test_roundtrip_plain_markdown(self) -> None: - """Test round-trip with plain markdown.""" - formatter = MarkdownFormat() - original = BacklogItem( - id="1", provider="test", url="", title="Test", body_markdown="Plain content", state="open" - ) - # Note: roundtrip_preserves_content checks id, provider, title, body_markdown, state, assignees, tags - # MarkdownFormat deserialize creates placeholder, so this will fail for id/provider - # But it preserves body_markdown which is the main content - serialized = formatter.serialize(original) - deserialized = formatter.deserialize(serialized) - assert deserialized.body_markdown == original.body_markdown diff --git a/tests/unit/backlog/test_structured_format.py b/tests/unit/backlog/test_structured_format.py deleted file mode 100644 index e69e6215..00000000 --- a/tests/unit/backlog/test_structured_format.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -Unit tests for StructuredFormat implementation (YAML/JSON). - -Tests YAML and JSON serialization and deserialization. -""" - -from __future__ import annotations - -import json - -import pytest -from beartype import beartype - -from specfact_cli.backlog.formats.structured_format import StructuredFormat -from specfact_cli.models.backlog_item import BacklogItem - - -class TestStructuredFormat: - """Test StructuredFormat implementation.""" - - @beartype - def test_format_type_yaml(self) -> None: - """Test format_type property for YAML.""" - formatter = StructuredFormat(format_type="yaml") - assert formatter.format_type == "yaml" - - @beartype - def test_format_type_json(self) -> None: - """Test format_type property for JSON.""" - formatter = StructuredFormat(format_type="json") - assert formatter.format_type == "json" - - @beartype - def test_format_type_invalid(self) -> None: - """Test invalid format_type raises ValueError.""" - with pytest.raises(ValueError, match="Format type must be 'yaml' or 'json'"): - StructuredFormat(format_type="invalid") - - @beartype - def test_serialize_yaml(self) -> None: - """Test YAML serialization.""" - formatter = StructuredFormat(format_type="yaml") - item = BacklogItem( - id="1", provider="test", url="http://test.com/1", title="Test", body_markdown="Body", state="open" - ) - serialized = formatter.serialize(item) - assert "id: '1'" in serialized or 'id: "1"' in serialized - assert "title: Test" in serialized - assert "state: open" in serialized - - @beartype - def test_serialize_json(self) -> None: - """Test JSON serialization.""" - formatter = StructuredFormat(format_type="json") - item = BacklogItem( - id="1", provider="test", url="http://test.com/1", title="Test", body_markdown="Body", state="open" - ) - serialized = formatter.serialize(item) - data = json.loads(serialized) - assert data["id"] == "1" - assert data["title"] == "Test" - assert data["state"] == "open" - - @beartype - def test_deserialize_yaml(self) -> None: - """Test YAML deserialization.""" - formatter = StructuredFormat(format_type="yaml") - yaml_content = """id: '1' -provider: test -url: http://test.com/1 -title: Test -body_markdown: Body -state: open""" - item = formatter.deserialize(yaml_content) - assert item.id == "1" - assert item.title == "Test" - assert item.body_markdown == "Body" - assert item.state == "open" - - @beartype - def test_deserialize_json(self) -> None: - """Test JSON deserialization.""" - formatter = StructuredFormat(format_type="json") - json_content = '{"id": "1", "provider": "test", "url": "http://test.com/1", "title": "Test", "body_markdown": "Body", "state": "open"}' - item = formatter.deserialize(json_content) - assert item.id == "1" - assert item.title == "Test" - assert item.body_markdown == "Body" - assert item.state == "open" - - @beartype - def test_roundtrip_yaml(self) -> None: - """Test YAML round-trip preserves content.""" - formatter = StructuredFormat(format_type="yaml") - original = BacklogItem( - id="1", - provider="test", - url="http://test.com/1", - title="Test Item", - body_markdown="Test body", - state="open", - assignees=["alice"], - tags=["feature"], - ) - assert formatter.roundtrip_preserves_content(original) is True - - @beartype - def test_roundtrip_json(self) -> None: - """Test JSON round-trip preserves content.""" - formatter = StructuredFormat(format_type="json") - original = BacklogItem( - id="1", - provider="test", - url="http://test.com/1", - title="Test Item", - body_markdown="Test body", - state="open", - assignees=["alice"], - tags=["feature"], - ) - assert formatter.roundtrip_preserves_content(original) is True - - @beartype - def test_roundtrip_preserves_provider_fields(self) -> None: - """Test round-trip preserves provider_fields.""" - formatter = StructuredFormat(format_type="yaml") - original = BacklogItem( - id="1", - provider="test", - url="", - title="Test", - body_markdown="Body", - state="open", - provider_fields={"custom_field": "value", "number": 123}, - ) - serialized = formatter.serialize(original) - deserialized = formatter.deserialize(serialized) - assert deserialized.provider_fields == original.provider_fields diff --git a/tests/unit/backlog/test_template_detector.py b/tests/unit/backlog/test_template_detector.py deleted file mode 100644 index 33c47584..00000000 --- a/tests/unit/backlog/test_template_detector.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -Unit tests for TemplateDetector. - -Tests template detection with various backlog item formats and confidence scoring. -""" - -from __future__ import annotations - -import pytest -from beartype import beartype - -from specfact_cli.backlog.template_detector import TemplateDetector, get_effective_required_sections -from specfact_cli.models.backlog_item import BacklogItem -from specfact_cli.templates.registry import BacklogTemplate, TemplateRegistry - - -@pytest.fixture -def template_registry() -> TemplateRegistry: - """Create template registry with test templates.""" - registry = TemplateRegistry() - - # User story template - user_story = BacklogTemplate( - template_id="user_story_v1", - name="User Story", - required_sections=["As a", "I want", "So that", "Acceptance Criteria"], - body_patterns={"as_a": "As a [^,]+ I want"}, - title_patterns=["^.*[Uu]ser [Ss]tory.*$"], - ) - registry.register_template(user_story) - - # Defect template - defect = BacklogTemplate( - template_id="defect_v1", - name="Defect", - required_sections=["Description", "Steps to Reproduce", "Expected Behavior", "Actual Behavior"], - body_patterns={"steps": "[Ss]teps? to [Rr]eproduce"}, - title_patterns=["^.*[Bb]ug.*$"], - ) - registry.register_template(defect) - - return registry - - -@pytest.fixture -def detector(template_registry: TemplateRegistry) -> TemplateDetector: - """Create template detector with test registry.""" - return TemplateDetector(template_registry) - - -class TestTemplateDetector: - """Test TemplateDetector.""" - - @beartype - def test_detect_high_confidence_match(self, detector: TemplateDetector) -> None: - """Test detecting template with high confidence.""" - item = BacklogItem( - id="1", - provider="github", - url="https://github.com/test/repo/issues/1", - title="User Story: Add login feature", - body_markdown="""## As a -user - -## I want -to log in - -## So that -I can access my account - -## Acceptance Criteria -- User can enter credentials -- User can click login button""", - state="open", - ) - - result = detector.detect_template(item) - - assert result.template_id == "user_story_v1" - assert result.confidence >= 0.8 - assert len(result.missing_fields) == 0 - - @beartype - def test_detect_medium_confidence_match(self, detector: TemplateDetector) -> None: - """Test detecting template with medium confidence (missing some sections).""" - item = BacklogItem( - id="2", - provider="github", - url="https://github.com/test/repo/issues/2", - title="User Story: Add feature", - body_markdown="""## As a -user - -## I want -to do something""", - state="open", - ) - - result = detector.detect_template(item) - - assert result.template_id == "user_story_v1" - assert 0.5 <= result.confidence < 0.8 - assert len(result.missing_fields) > 0 - - @beartype - def test_detect_no_match(self, detector: TemplateDetector) -> None: - """Test detecting no template match.""" - item = BacklogItem( - id="3", - provider="github", - url="https://github.com/test/repo/issues/3", - title="Random issue", - body_markdown="Some random content without structure", - state="open", - ) - - result = detector.detect_template(item) - - assert result.template_id is None - assert result.confidence < 0.5 - - @beartype - def test_detect_defect_template(self, detector: TemplateDetector) -> None: - """Test detecting defect template.""" - item = BacklogItem( - id="4", - provider="github", - url="https://github.com/test/repo/issues/4", - title="Bug: Login fails", - body_markdown="""## Description -Login doesn't work - -## Steps to Reproduce -1. Go to login page -2. Enter credentials -3. Click login - -## Expected Behavior -User should be logged in - -## Actual Behavior -Error message appears""", - state="open", - ) - - result = detector.detect_template(item) - - assert result.template_id == "defect_v1" - assert result.confidence >= 0.8 - - @beartype - def test_detect_with_pattern_match(self, detector: TemplateDetector) -> None: - """Test detection with pattern matching.""" - item = BacklogItem( - id="5", - provider="github", - url="https://github.com/test/repo/issues/5", - title="User Story: Feature X", - body_markdown="As a developer I want to add features", - state="open", - ) - - result = detector.detect_template(item) - - # Should match user story based on title pattern and body pattern - assert result.template_id == "user_story_v1" - assert result.confidence > 0.0 - - @beartype - def test_detect_arbitrary_input(self, detector: TemplateDetector) -> None: - """Test detection with arbitrary DevOps backlog input.""" - # Simulate arbitrary input that DevOps team might put in - item = BacklogItem( - id="6", - provider="github", - url="https://github.com/test/repo/issues/6", - title="Need to fix the thing", - body_markdown="""Hey team, -We need to fix this issue. It's been reported by users. -Can someone look into it? - -Thanks!""", - state="open", - ) - - result = detector.detect_template(item) - - # Should not match any template (low or no confidence) - assert result.confidence < 0.5 - - @beartype - def test_detect_with_persona_framework_provider_filtering(self, template_registry: TemplateRegistry) -> None: - """Test template detection with persona/framework/provider filtering.""" - # Add framework-specific template - scrum_template = BacklogTemplate( - template_id="scrum_story_v1", - name="Scrum User Story", - framework="scrum", - required_sections=["As a", "I want"], - body_patterns={"as_a": "As a [^,]+ I want"}, - ) - template_registry.register_template(scrum_template) - - detector = TemplateDetector(template_registry) - - item = BacklogItem( - id="7", - provider="github", - url="https://github.com/test/repo/issues/7", - title="User Story: Add feature", - body_markdown="""## As a -user - -## I want -to add features""", - state="open", - ) - - # Test with framework filter - result = detector.detect_template(item, provider="github", framework="scrum") - assert result.template_id == "scrum_story_v1" - - # Test without framework filter (should match default) - result = detector.detect_template(item, provider="github", framework=None) - assert result.template_id in ["user_story_v1", "scrum_story_v1"] # Either could match - - @beartype - def test_ado_effective_required_sections_ignores_structured_metric_sections(self) -> None: - """ADO should not require structured metric sections in markdown body.""" - template = BacklogTemplate( - template_id="scrum_user_story_v1", - name="Scrum User Story", - required_sections=["As a", "I want", "So that", "Acceptance Criteria", "Story Points"], - ) - item = BacklogItem( - id="8", - provider="ado", - url="https://dev.azure.com/org/project/_workitems/edit/8", - title="User Story", - body_markdown="## As a\nuser\n\n## I want\nvalue\n\n## So that\nbenefit\n\n## Acceptance Criteria\n- [ ] done", - state="Active", - ) - effective = get_effective_required_sections(item, template) - assert "Story Points" not in effective - assert "Acceptance Criteria" in effective diff --git a/tests/unit/commands/test_backlog_ceremony_group.py b/tests/unit/commands/test_backlog_ceremony_group.py deleted file mode 100644 index fb4f8df9..00000000 --- a/tests/unit/commands/test_backlog_ceremony_group.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for backlog ceremony command group aliases.""" - -from __future__ import annotations - -import pytest -from typer.testing import CliRunner - - -pytest.importorskip("specfact_backlog.backlog.commands") -from specfact_backlog.backlog import commands as backlog_commands - - -runner = CliRunner() - - -def test_backlog_ceremony_group_exposes_standup_and_refinement() -> None: - """`specfact backlog ceremony -h` lists ceremony-focused subcommands.""" - result = runner.invoke(backlog_commands.app, ["ceremony", "-h"]) - assert result.exit_code == 0 - assert "standup" in result.stdout - assert "refinement" in result.stdout - assert "planning" in result.stdout - assert "flow" in result.stdout - assert "pi-summary" in result.stdout - - -def test_ceremony_standup_delegates_to_backlog_daily(monkeypatch) -> None: - """`backlog ceremony standup` delegates to daily behavior.""" - monkeypatch.setattr(backlog_commands, "_fetch_backlog_items", lambda *args, **kwargs: []) - result = runner.invoke(backlog_commands.app, ["ceremony", "standup", "github"]) - assert result.exit_code == 0 - assert "No backlog items found." in result.stdout - - -def test_ceremony_refinement_delegates_to_backlog_refine(monkeypatch) -> None: - """`backlog ceremony refinement` delegates to refine behavior.""" - monkeypatch.setattr(backlog_commands, "_fetch_backlog_items", lambda *args, **kwargs: []) - result = runner.invoke(backlog_commands.app, ["ceremony", "refinement", "github"]) - assert result.exit_code == 0 - assert "No backlog items found." in result.stdout - - -def test_ceremony_planning_shows_clear_message_when_target_command_missing() -> None: - """`backlog ceremony planning` fails clearly when delegated command is unavailable.""" - result = runner.invoke(backlog_commands.app, ["ceremony", "planning", "github"]) - assert result.exit_code != 0 - assert "requires an installed backlog module" in result.stdout - assert "sprint-summary" in result.stdout diff --git a/tests/unit/commands/test_backlog_commands.py b/tests/unit/commands/test_backlog_commands.py deleted file mode 100644 index 68f89a9b..00000000 --- a/tests/unit/commands/test_backlog_commands.py +++ /dev/null @@ -1,1833 +0,0 @@ -""" -Unit tests for backlog commands. - -Tests for backlog refinement commands, including preview output and filtering. -""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -import yaml -from rich.panel import Panel -from typer.testing import CliRunner - - -pytest.importorskip("specfact_backlog.backlog.commands") -from specfact_backlog.backlog.commands import ( - _apply_issue_window, - _build_comment_fetch_progress_description, - _build_refine_export_content, - _build_refine_preview_comment_empty_panel, - _build_refine_preview_comment_panels, - _detect_significant_content_loss, - _fetch_backlog_items, - _item_needs_refinement, - _parse_refined_export_markdown, - _parse_refinement_output_fields, - _resolve_backlog_provider_framework, - _resolve_refine_export_comment_window, - _resolve_refine_preview_comment_window, - _resolve_target_template_for_refine_item, - app as backlog_app, -) - -from specfact_cli.backlog.adapters.base import BacklogAdapter as CoreBacklogAdapter -from specfact_cli.backlog.template_detector import TemplateDetector -from specfact_cli.cli import app -from specfact_cli.models.backlog_item import BacklogItem -from specfact_cli.templates.registry import BacklogTemplate, TemplateRegistry - - -runner = CliRunner() - - -@pytest.fixture(autouse=True) -def _bootstrap_registry_for_backlog_commands(): - """Ensure registry is bootstrapped so root 'backlog' resolves to the group with init-config, map-fields, etc.""" - from specfact_cli.registry.bootstrap import register_builtin_commands - from specfact_cli.registry.registry import CommandRegistry - - CommandRegistry._clear_for_testing() - register_builtin_commands() - yield - CommandRegistry._clear_for_testing() - - -@patch("specfact_backlog.backlog.commands._resolve_standup_options") -@patch("specfact_backlog.backlog.commands._fetch_backlog_items") -def test_daily_issue_id_bypasses_implicit_default_state( - mock_fetch_backlog_items: MagicMock, - mock_resolve_standup_options: MagicMock, -) -> None: - """`backlog daily --id` should not apply implicit default state/assignee filters.""" - mock_resolve_standup_options.return_value = ("open", 20, "me") - mock_fetch_backlog_items.return_value = [ - BacklogItem( - id="185", - provider="ado", - url="https://dev.azure.com/org/project/_apis/wit/workitems/185", - title="Fix the error", - body_markdown="Description", - state="new", - assignees=["dominikus.nold@web.de"], - ) - ] - - result = runner.invoke( - backlog_app, - [ - "daily", - "ado", - "--ado-org", - "dominikusnold", - "--ado-project", - "Specfact CLI", - "--id", - "185", - ], - ) - - assert result.exit_code == 0 - assert "No backlog item with id" not in result.stdout - assert mock_fetch_backlog_items.call_args.kwargs["state"] is None - assert mock_fetch_backlog_items.call_args.kwargs["assignee"] is None - - -@patch("specfact_backlog.backlog.commands._resolve_standup_options") -@patch("specfact_backlog.backlog.commands._fetch_backlog_items") -def test_daily_reports_default_filters_when_no_items( - mock_fetch_backlog_items: MagicMock, - mock_resolve_standup_options: MagicMock, -) -> None: - """`backlog daily` should show implicit defaults in UI output for empty results.""" - mock_resolve_standup_options.return_value = ("open", 20, "me") - mock_fetch_backlog_items.return_value = [] - - result = runner.invoke( - backlog_app, - [ - "daily", - "ado", - "--ado-org", - "dominikusnold", - "--ado-project", - "Specfact CLI", - ], - ) - - assert result.exit_code == 0 - assert "Applied filters:" in result.stdout - assert "state=open (default)" in result.stdout - assert "assignee=me" in result.stdout - assert "(default)" in result.stdout - assert "limit=20 (default)" in result.stdout - - -@patch("specfact_backlog.backlog.commands._resolve_standup_options") -@patch("specfact_backlog.backlog.commands._fetch_backlog_items") -def test_daily_accepts_any_for_state_and_assignee_as_no_filter( - mock_fetch_backlog_items: MagicMock, - mock_resolve_standup_options: MagicMock, -) -> None: - """`--state any` / `--assignee any` should disable both filters.""" - mock_resolve_standup_options.return_value = (None, 20, None) - mock_fetch_backlog_items.return_value = [] - - result = runner.invoke( - backlog_app, - [ - "daily", - "ado", - "--ado-org", - "dominikusnold", - "--ado-project", - "Specfact CLI", - "--state", - "any", - "--assignee", - "any", - ], - ) - - assert result.exit_code == 0 - assert mock_resolve_standup_options.call_args.kwargs["state_filter_disabled"] is True - assert mock_resolve_standup_options.call_args.kwargs["assignee_filter_disabled"] is True - assert mock_fetch_backlog_items.call_args.kwargs["state"] is None - assert mock_fetch_backlog_items.call_args.kwargs["assignee"] is None - - -@patch("specfact_backlog.backlog.commands._fetch_backlog_items") -def test_daily_any_filters_render_as_disabled_scope( - mock_fetch_backlog_items: MagicMock, -) -> None: - """`--state any --assignee any` should render disabled filter scope in output.""" - mock_fetch_backlog_items.return_value = [] - - result = runner.invoke( - backlog_app, - [ - "daily", - "ado", - "--ado-org", - "dominikusnold", - "--ado-project", - "Specfact CLI", - "--state", - "any", - "--assignee", - "any", - ], - ) - - assert result.exit_code == 0 - output = " ".join(result.stdout.split()) - assert "Applied filters:" in output - assert "state=— (explicit)" in output - assert "assignee=— (explicit)" in output - - -class TestBacklogPreviewOutput: - """Tests for backlog preview output display.""" - - def test_preview_output_displays_assignee(self) -> None: - """Test that preview output displays assignee information.""" - item = BacklogItem( - id="123", - provider="ado", - url="https://dev.azure.com/org/project/_apis/wit/workitems/123", - title="Test Item", - body_markdown="Description", - state="New", - assignees=["John Doe", "john@example.com"], - ) - - # Verify assignees are set correctly - assert len(item.assignees) == 2 - assert "John Doe" in item.assignees - assert "john@example.com" in item.assignees - - def test_preview_output_displays_unassigned(self) -> None: - """Test that preview output displays 'Unassigned' when no assignees.""" - item = BacklogItem( - id="124", - provider="ado", - url="https://dev.azure.com/org/project/_apis/wit/workitems/124", - title="Test Item", - body_markdown="Description", - state="New", - assignees=[], - ) - - # Verify empty assignees list - assert item.assignees == [] - - def test_preview_output_assignee_format(self) -> None: - """Test that assignee display format is correct.""" - item = BacklogItem( - id="125", - provider="ado", - url="https://dev.azure.com/org/project/_apis/wit/workitems/125", - title="Test Item", - body_markdown="Description", - state="New", - assignees=["Jane Smith"], - ) - - # Format should be: ', '.join(item.assignees) if item.assignees else 'Unassigned' - assignee_display = ", ".join(item.assignees) if item.assignees else "Unassigned" - assert assignee_display == "Jane Smith" - - # Test unassigned format - item_unassigned = BacklogItem( - id="126", - provider="ado", - url="https://dev.azure.com/org/project/_apis/wit/workitems/126", - title="Test Item", - body_markdown="Description", - state="New", - assignees=[], - ) - assignee_display_unassigned = ( - ", ".join(item_unassigned.assignees) if item_unassigned.assignees else "Unassigned" - ) - assert assignee_display_unassigned == "Unassigned" - - -def test_fetch_backlog_items_accepts_core_backlog_adapter(monkeypatch: pytest.MonkeyPatch) -> None: - """Bundle backlog commands must accept adapters implementing the core BacklogAdapter contract.""" - - class _CoreAdapter(CoreBacklogAdapter): - def name(self) -> str: - return "ado" - - def supports_format(self, format_type: str) -> bool: - _ = format_type - return True - - def fetch_backlog_items(self, filters): # type: ignore[no-untyped-def] - _ = filters - return [ - BacklogItem( - id="185", - provider="ado", - url="https://dev.azure.com/org/project/_apis/wit/workitems/185", - title="Fix the error", - body_markdown="Description", - state="New", - ) - ] - - def update_backlog_item(self, item: BacklogItem, update_fields: list[str] | None = None) -> BacklogItem: - _ = update_fields - return item - - class _Registry: - def get_adapter(self, *_args, **_kwargs): # type: ignore[no-untyped-def] - return _CoreAdapter() - - monkeypatch.setattr("specfact_backlog.backlog.commands.AdapterRegistry", lambda: _Registry()) - - items = _fetch_backlog_items( - "ado", - ado_org="test-org", - ado_project="test-project", - ado_token="test-token", - limit=1, - ) - - assert len(items) == 1 - assert items[0].id == "185" - - -class TestInteractiveMappingCommand: - """Tests for interactive template mapping command.""" - - @patch("requests.get") - @patch("questionary.select") - @patch("rich.prompt.Prompt.ask") - @patch("rich.prompt.Confirm.ask") - def test_map_fields_fetches_ado_fields( - self, - mock_confirm: MagicMock, - mock_prompt: MagicMock, - mock_select: MagicMock, - mock_get: MagicMock, - ) -> None: - """Test that map-fields command fetches ADO metadata endpoints.""" - # Mock ADO API response - mock_response = MagicMock() - mock_response.json.return_value = { - "value": [ - { - "referenceName": "System.Description", - "name": "Description", - "type": "html", - }, - { - "referenceName": "Microsoft.VSTS.Common.AcceptanceCriteria", - "name": "Acceptance Criteria", - "type": "html", - }, - ] - } - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - # Mock rich.prompt.Prompt to avoid interactive input - mock_prompt.return_value = "" - mock_confirm.return_value = False - mock_select.return_value.ask.return_value = None - - runner.invoke( - app, - [ - "backlog", - "map-fields", - "--ado-org", - "test-org", - "--ado-project", - "test-project", - "--ado-token", - "test-token", - ], - ) - - # Should call ADO API - assert mock_get.called - called_urls = [str(call.args[0]) for call in mock_get.call_args_list if call.args] - assert any("test-org" in url for url in called_urls) - assert any("test-project" in url for url in called_urls) - # map-fields now resolves/processes work-item type metadata before field mapping prompts - assert any("_apis/wit/workitemtypes" in url for url in called_urls) - - @patch("requests.get") - @patch("questionary.select") - @patch("rich.prompt.Prompt.ask") - @patch("rich.prompt.Confirm.ask") - def test_map_fields_filters_system_fields( - self, - mock_confirm: MagicMock, - mock_prompt: MagicMock, - mock_select: MagicMock, - mock_get: MagicMock, - ) -> None: - """Test that map-fields command filters out system-only fields.""" - # Mock ADO API response with system and user fields - mock_response = MagicMock() - mock_response.json.return_value = { - "value": [ - {"referenceName": "System.Id", "name": "ID", "type": "integer"}, # System field - should be filtered - { - "referenceName": "System.Rev", - "name": "Revision", - "type": "integer", - }, # System field - should be filtered - { - "referenceName": "System.Description", - "name": "Description", - "type": "html", - }, # User field - should be included - { - "referenceName": "Microsoft.VSTS.Common.AcceptanceCriteria", - "name": "Acceptance Criteria", - "type": "html", - }, # User field - should be included - ] - } - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - # Mock rich.prompt.Prompt to avoid interactive input - mock_prompt.return_value = "" - mock_confirm.return_value = False - mock_select.return_value.ask.return_value = None - - runner.invoke( - app, - [ - "backlog", - "map-fields", - "--ado-org", - "test-org", - "--ado-project", - "test-project", - "--ado-token", - "test-token", - ], - ) - - # Command should execute (even if user cancels) - # The filtering logic is tested implicitly by checking that system fields are excluded - assert mock_get.called - - def test_map_fields_requires_token(self) -> None: - """Test that map-fields command requires ADO token.""" - result = runner.invoke( - app, - [ - "backlog", - "map-fields", - "--ado-org", - "test-org", - "--ado-project", - "test-project", - ], - env={"AZURE_DEVOPS_TOKEN": ""}, # Empty token - ) - - # Should fail with error about missing token - assert result.exit_code != 0 - out = result.output or result.stdout or "" - assert "token required" in out.lower() or "error" in out.lower() - - @patch("questionary.checkbox") - @patch("specfact_cli.utils.auth_tokens.get_token") - @patch("requests.post") - def test_map_fields_provider_picker_accepts_choice_objects( - self, - mock_post: MagicMock, - mock_get_token: MagicMock, - mock_checkbox: MagicMock, - tmp_path, - ) -> None: - """Provider picker should accept questionary Choice-like objects with `.value`.""" - - class _ChoiceLike: - def __init__(self, value: str) -> None: - self.value = value - - mock_checkbox.return_value.ask.return_value = [_ChoiceLike("github")] - mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = { - "data": {"repository": {"issueTypes": {"nodes": [{"id": "IT_TASK", "name": "Task"}]}}} - } - mock_post.return_value = mock_response - - import os - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke( - backlog_app, - [ - "map-fields", - "--github-project-id", - "nold-ai/specfact-demo-repo", - "--github-project-v2-id", - "PVT_project_id", - "--github-type-field-id", - "PVT_type_field", - "--github-type-option", - "task=OPT_TASK", - ], - ) - finally: - os.chdir(cwd) - - assert result.exit_code == 0 - assert "No providers selected" not in result.stdout - - @patch("specfact_cli.utils.auth_tokens.get_token") - @patch("requests.post") - def test_map_fields_github_provider_persists_backlog_config( - self, mock_post: MagicMock, mock_get_token: MagicMock, tmp_path - ) -> None: - """Test GitHub provider mapping persistence into .specfact/backlog-config.yaml.""" - mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = { - "data": { - "repository": { - "issueTypes": { - "nodes": [ - {"id": "IT_BUG", "name": "Bug"}, - {"id": "IT_TASK", "name": "Task"}, - ] - } - } - } - } - mock_post.return_value = mock_response - import os - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke( - backlog_app, - [ - "map-fields", - "--provider", - "github", - "--github-project-id", - "nold-ai/specfact-demo-repo", - "--github-project-v2-id", - "PVT_project_id", - "--github-type-field-id", - "PVT_type_field", - "--github-type-option", - "task=OPT_TASK", - ], - ) - finally: - os.chdir(cwd) - - assert result.exit_code == 0 - cfg_file = tmp_path / ".specfact" / "backlog-config.yaml" - assert cfg_file.exists() - loaded = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) - github_settings = loaded["backlog_config"]["providers"]["github"]["settings"] - mapping = github_settings["provider_fields"]["github_project_v2"] - assert mapping["project_id"] == "PVT_project_id" - assert mapping["type_field_id"] == "PVT_type_field" - assert mapping["type_option_ids"]["task"] == "OPT_TASK" - assert github_settings["github_issue_types"]["type_ids"]["task"] == "IT_TASK" - assert github_settings["github_issue_types"]["type_ids"]["bug"] == "IT_BUG" - assert github_settings["field_mapping_file"] == ".specfact/templates/backlog/field_mappings/github_custom.yaml" - github_custom = tmp_path / ".specfact" / "templates" / "backlog" / "field_mappings" / "github_custom.yaml" - assert github_custom.exists() - github_custom_payload = yaml.safe_load(github_custom.read_text(encoding="utf-8")) - assert github_custom_payload["type_mapping"]["task"] == "task" - - @patch("specfact_cli.utils.auth_tokens.get_token") - @patch("requests.post") - def test_map_fields_github_provider_maps_story_from_user_story_type( - self, mock_post: MagicMock, mock_get_token: MagicMock, tmp_path - ) -> None: - """GitHub map-fields should map canonical story to discovered custom User Story type.""" - mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = { - "data": { - "repository": { - "issueTypes": { - "nodes": [ - {"id": "IT_FEATURE", "name": "Feature"}, - {"id": "IT_USER_STORY", "name": "User Story"}, - {"id": "IT_TASK", "name": "Task"}, - ] - } - } - } - } - mock_post.return_value = mock_response - - import os - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke( - backlog_app, - [ - "map-fields", - "--provider", - "github", - "--github-project-id", - "nold-ai/specfact-demo-repo", - "--github-project-v2-id", - "PVT_project_id", - "--github-type-field-id", - "PVT_type_field", - "--github-type-option", - "task=OPT_TASK", - ], - ) - finally: - os.chdir(cwd) - - assert result.exit_code == 0 - assert "story => user story (fallback alias)" in result.stdout.lower() - cfg_file = tmp_path / ".specfact" / "backlog-config.yaml" - loaded = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) - github_settings = loaded["backlog_config"]["providers"]["github"]["settings"] - issue_type_ids = github_settings["github_issue_types"]["type_ids"] - assert issue_type_ids["user story"] == "IT_USER_STORY" - assert issue_type_ids["story"] == "IT_USER_STORY" - - @patch("specfact_cli.utils.auth_tokens.get_token") - @patch("requests.post") - def test_map_fields_github_provider_fails_when_issue_types_unavailable( - self, mock_post: MagicMock, mock_get_token: MagicMock, tmp_path - ) -> None: - """GitHub map-fields should fail when repository issue type IDs cannot be discovered.""" - mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"data": {"repository": {"issueTypes": {"nodes": []}}}} - mock_post.return_value = mock_response - - import os - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke( - backlog_app, - [ - "map-fields", - "--provider", - "github", - "--github-project-id", - "nold-ai/specfact-demo-repo", - "--github-project-v2-id", - "PVT_project_id", - "--github-type-field-id", - "PVT_type_field", - "--github-type-option", - "task=OPT_TASK", - ], - ) - finally: - os.chdir(cwd) - - assert result.exit_code != 0 - assert "repository issue types" in result.stdout.lower() - - @patch("questionary.checkbox") - @patch("specfact_backlog.backlog.commands.typer.prompt") - @patch("specfact_cli.utils.auth_tokens.get_token") - @patch("requests.post") - def test_map_fields_github_provider_allows_blank_project_v2( - self, - mock_post: MagicMock, - mock_get_token: MagicMock, - mock_prompt: MagicMock, - mock_checkbox: MagicMock, - tmp_path, - ) -> None: - """GitHub map-fields should not require ProjectV2 when repository issue types are available.""" - mock_checkbox.return_value.ask.return_value = ["github"] - mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} - mock_prompt.side_effect = [ - "nold-ai/specfact-demo-repo", # owner/repo - "", # blank project ref (optional) - ] - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = { - "data": { - "repository": { - "issueTypes": { - "nodes": [ - {"id": "IT_BUG", "name": "Bug"}, - {"id": "IT_TASK", "name": "Task"}, - ] - } - } - } - } - mock_post.return_value = mock_response - - import os - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke(backlog_app, ["map-fields"]) - finally: - os.chdir(cwd) - - assert result.exit_code == 0 - assert "projectv2 type field mapping skipped" in result.stdout.lower() - cfg_file = tmp_path / ".specfact" / "backlog-config.yaml" - assert cfg_file.exists() - loaded = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) - github_settings = loaded["backlog_config"]["providers"]["github"]["settings"] - assert github_settings["github_issue_types"]["type_ids"]["task"] == "IT_TASK" - provider_fields = github_settings.get("provider_fields", {}) - if isinstance(provider_fields, dict): - assert provider_fields.get("github_project_v2") is None - - @patch("questionary.checkbox") - @patch("specfact_backlog.backlog.commands.typer.prompt") - @patch("specfact_cli.utils.auth_tokens.get_token") - @patch("requests.post") - def test_map_fields_blank_project_v2_clears_stale_project_mapping( - self, - mock_post: MagicMock, - mock_get_token: MagicMock, - mock_prompt: MagicMock, - mock_checkbox: MagicMock, - tmp_path, - ) -> None: - """Blank ProjectV2 input should clear stale ProjectV2 provider_fields mapping.""" - spec_dir = tmp_path / ".specfact" - spec_dir.mkdir(parents=True, exist_ok=True) - (spec_dir / "backlog-config.yaml").write_text( - """ -backlog_config: - providers: - github: - adapter: github - project_id: nold-ai/specfact-demo-repo - settings: - provider_fields: - github_project_v2: - project_id: PVT_project_id - type_field_id: PVT_type_field - type_option_ids: - task: PVT_option_task -""".strip(), - encoding="utf-8", - ) - mock_checkbox.return_value.ask.return_value = ["github"] - mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} - mock_prompt.side_effect = [ - "nold-ai/specfact-demo-repo", - "", - ] - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = { - "data": {"repository": {"issueTypes": {"nodes": [{"id": "IT_TASK", "name": "Task"}]}}} - } - mock_post.return_value = mock_response - - import os - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke(backlog_app, ["map-fields"]) - finally: - os.chdir(cwd) - - assert result.exit_code == 0 - loaded = yaml.safe_load((spec_dir / "backlog-config.yaml").read_text(encoding="utf-8")) - github_settings = loaded["backlog_config"]["providers"]["github"]["settings"] - provider_fields = github_settings.get("provider_fields", {}) - assert provider_fields.get("github_project_v2") is None - - @patch("requests.get") - @patch("questionary.select") - def test_map_fields_ado_framework_cli_persists_to_config_and_mapping( - self, mock_select: MagicMock, mock_get: MagicMock, tmp_path - ) -> None: - """ADO map-fields should persist selected framework for deterministic refine steering.""" - # ADO fields API response - mock_fields_response = MagicMock() - mock_fields_response.raise_for_status.return_value = None - mock_fields_response.json.return_value = { - "value": [ - {"referenceName": "System.Description", "name": "Description"}, - {"referenceName": "System.AcceptanceCriteria", "name": "Acceptance Criteria"}, - {"referenceName": "Microsoft.VSTS.Scheduling.StoryPoints", "name": "Story Points"}, - ] - } - # ADO work item types API response (detection call; should not override explicit CLI value) - mock_types_response = MagicMock() - mock_types_response.raise_for_status.return_value = None - mock_types_response.json.return_value = { - "value": [{"name": "Product Backlog Item"}, {"name": "Bug"}, {"name": "Task"}] - } - mock_get.side_effect = [mock_fields_response, mock_types_response] - - # Field selection prompts: map none for all canonical fields - mock_select.return_value.ask.return_value = "<no mapping>" - - import os - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke( - backlog_app, - [ - "map-fields", - "--provider", - "ado", - "--ado-org", - "test-org", - "--ado-project", - "test-project", - "--ado-token", - "test-token", - "--ado-framework", - "scrum", - ], - ) - finally: - os.chdir(cwd) - - assert result.exit_code == 0 - ado_custom = tmp_path / ".specfact" / "templates" / "backlog" / "field_mappings" / "ado_custom.yaml" - assert ado_custom.exists() - custom_payload = yaml.safe_load(ado_custom.read_text(encoding="utf-8")) - assert custom_payload["framework"] == "scrum" - - cfg_file = tmp_path / ".specfact" / "backlog-config.yaml" - assert cfg_file.exists() - loaded = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) - ado_settings = loaded["backlog_config"]["providers"]["ado"]["settings"] - assert ado_settings["framework"] == "scrum" - - def test_resolve_backlog_provider_framework_reads_backlog_config(self, tmp_path) -> None: - """Framework resolver should read provider framework from backlog-config settings.""" - import os - - spec_dir = tmp_path / ".specfact" - spec_dir.mkdir(parents=True, exist_ok=True) - (spec_dir / "backlog-config.yaml").write_text( - """ -backlog_config: - providers: - ado: - adapter: ado - project_id: test-org/test-project - settings: - framework: scrum - field_mapping_file: .specfact/templates/backlog/field_mappings/ado_custom.yaml -""".strip(), - encoding="utf-8", - ) - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - resolved = _resolve_backlog_provider_framework("ado") - finally: - os.chdir(cwd) - - assert resolved == "scrum" - - def test_backlog_init_config_scaffolds_default_file(self, tmp_path) -> None: - """Test backlog init-config creates default backlog-config scaffold.""" - import os - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke(app, ["backlog", "init-config"]) - finally: - os.chdir(cwd) - - assert result.exit_code == 0 - cfg_file = tmp_path / ".specfact" / "backlog-config.yaml" - assert cfg_file.exists() - loaded = yaml.safe_load(cfg_file.read_text(encoding="utf-8")) - assert "backlog_config" in loaded - assert "providers" in loaded["backlog_config"] - assert "github" in loaded["backlog_config"]["providers"] - assert "ado" in loaded["backlog_config"]["providers"] - - def test_backlog_init_config_does_not_overwrite_without_force(self, tmp_path) -> None: - """Test backlog init-config respects no-overwrite behavior by default.""" - import os - - cfg_dir = tmp_path / ".specfact" - cfg_dir.mkdir(parents=True, exist_ok=True) - cfg_file = cfg_dir / "backlog-config.yaml" - cfg_file.write_text("backlog_config:\n providers:\n github:\n adapter: github\n", encoding="utf-8") - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - result = runner.invoke(app, ["backlog", "init-config"]) - finally: - os.chdir(cwd) - - assert result.exit_code == 0 - content = cfg_file.read_text(encoding="utf-8") - assert "adapter: github" in content - assert "already exists" in (result.output or result.stdout or "").lower() - - -class TestParseRefinedExportMarkdown: - """Tests for _parse_refined_export_markdown (refine --import-from-tmp parser).""" - - def test_parses_single_item_with_body_and_id(self) -> None: - """Parser extracts ID and body from export-format block.""" - content = """ -# SpecFact Backlog Refinement Export - -**Export Date**: 2026-01-27 -**Adapter**: github -**Items**: 1 - ---- - -## Item 1: My Title - -**ID**: issue-42 -**URL**: https://github.com/org/repo/issues/42 -**State**: open -**Provider**: github - -**Body**: -```markdown -Refined body text here. -``` -""" - result = _parse_refined_export_markdown(content) - assert "issue-42" in result - assert result["issue-42"]["body_markdown"] == "Refined body text here." - assert result["issue-42"].get("title") == "My Title" - - def test_parses_item_when_file_starts_with_item_header(self) -> None: - """Parser handles item heading at file start and does not leak heading marker into title.""" - content = """## Item 1: Story title from heading - -**ID**: 123 -**URL**: u -**State**: open -**Provider**: ado - -**Body**: -```markdown -Body content -``` -""" - result = _parse_refined_export_markdown(content) - assert "123" in result - assert result["123"].get("title") == "Story title from heading" - assert result["123"].get("title", "").startswith("## Item") is False - - def test_parses_acceptance_criteria_and_metrics(self) -> None: - """Parser extracts acceptance criteria and metrics when present.""" - content = """ -## Item 1: Story title - -**ID**: 123 -**URL**: u -**State**: open -**Provider**: ado - -**Metrics**: -- Story Points: 5 -- Business Value: 8 -- Priority: 1 (1=highest) - -**Acceptance Criteria**: -- AC one -- AC two - -**Body**: -```markdown -Body content -``` ---- -""" - result = _parse_refined_export_markdown(content) - assert "123" in result - assert result["123"]["acceptance_criteria"] == "- AC one\n- AC two" - assert result["123"]["story_points"] == 5 - assert result["123"]["business_value"] == 8 - assert result["123"]["priority"] == 1 - assert result["123"]["body_markdown"] == "Body content" - - def test_returns_empty_for_header_only(self) -> None: - """Parser returns empty dict when no ## Item blocks.""" - content = "# SpecFact Backlog Refinement Export\n\n**Items**: 0\n\n---\n\n" - result = _parse_refined_export_markdown(content) - assert result == {} - - def test_skips_blocks_without_id(self) -> None: - """Parser skips blocks that do not contain **ID**:.""" - content = """ -## Item 1: No ID here - -**URL**: x -**Body**: -```markdown -nope -``` -""" - result = _parse_refined_export_markdown(content) - assert result == {} - - def test_body_with_nested_fenced_code_blocks(self) -> None: - """Parser preserves full body when it contains fenced code blocks.""" - content = """ -## Item 1: Bug with code sample - -**ID**: issue-99 -**URL**: https://github.com/org/repo/issues/99 -**State**: open -**Provider**: github - -**Body**: -```markdown -Reproduction: run this: - -```python -def foo(): - return 42 -``` - -Then we see the error. -``` ---- -""" - result = _parse_refined_export_markdown(content) - assert "issue-99" in result - body = result["issue-99"]["body_markdown"] - assert "Reproduction: run this:" in body - assert "```python" in body - assert "def foo():" in body - assert "return 42" in body - assert "```" in body - assert "Then we see the error." in body - - -class TestContentLossDetection: - """Tests for refined-content loss guard used by tmp import.""" - - def test_detects_significant_content_loss(self) -> None: - original = ( - "Implement OAuth login with PKCE, refresh-token rotation, role-based access checks, " - "audit logging for login events, and explicit error handling for expired tokens." - ) - refined = "Implement login support." - has_loss, reason = _detect_significant_content_loss(original, refined) - assert has_loss is True - assert reason - - def test_allows_structured_rewrite_without_loss(self) -> None: - original = ( - "As a platform user I need OAuth login with PKCE and refresh-token rotation so that " - "authentication remains secure and users can re-authenticate without credential prompts." - ) - refined = ( - "## Description\n\nAs a platform user I need OAuth login with PKCE and refresh-token rotation " - "so authentication stays secure and re-authentication works without credential prompts." - ) - has_loss, _reason = _detect_significant_content_loss(original, refined) - assert has_loss is False - - -class TestParseRefinementOutputFields: - """Tests for parser that normalizes refinement output for writeback.""" - - def test_parses_label_style_refinement_output(self) -> None: - """Parser splits label-style sections into canonical fields.""" - refined = """ -Description: -The sync between ADO and OpenSpec should preserve markdown. - -Acceptance Criteria: -- [ ] Description is not overwritten with prompt labels -- [ ] Acceptance criteria maps to dedicated ADO field - -Notes: -Keep backward compatibility. - -Dependencies: -- backlog adapter - -Area Path: -(unspecified) - -Iteration Path: -(unspecified) - -Story Points: -5 - -Business Value: -8 - -Priority: -2 - -Work Item Type: -User Story - -Provider: -ado -""" - parsed = _parse_refinement_output_fields(refined) - assert parsed["description"] == "The sync between ADO and OpenSpec should preserve markdown." - assert parsed["acceptance_criteria"] == ( - "- [ ] Description is not overwritten with prompt labels\n" - "- [ ] Acceptance criteria maps to dedicated ADO field" - ) - assert parsed["story_points"] == 5 - assert parsed["business_value"] == 8 - assert parsed["priority"] == 2 - assert parsed["work_item_type"] == "User Story" - assert "Area Path" not in (parsed.get("body_markdown") or "") - assert "Iteration Path" not in (parsed.get("body_markdown") or "") - assert "Provider:" not in (parsed.get("body_markdown") or "") - assert "## Notes" in (parsed.get("body_markdown") or "") - assert "## Dependencies" in (parsed.get("body_markdown") or "") - - def test_parses_markdown_heading_refinement_output(self) -> None: - """Parser extracts canonical fields from markdown-heading format.""" - refined = """ -User-facing summary. - -## Acceptance Criteria - -- first -- second - -## Story Points - -3 - -## Business Value - -13 - -## Priority - -1 -""" - parsed = _parse_refinement_output_fields(refined) - assert parsed["description"] == "User-facing summary." - assert parsed["acceptance_criteria"] == "- first\n- second" - assert parsed["story_points"] == 3 - assert parsed["business_value"] == 13 - assert parsed["priority"] == 1 - - def test_preserves_heading_style_notes_and_dependencies_in_body_markdown(self) -> None: - """Heading-style narrative sections should be preserved in writeback body.""" - refined = """ -User-facing summary. - -## Acceptance Criteria - -- first - -## Notes - -Keep this narrative note. - -## Dependencies - -- Team A - -## Story Points - -5 - -## Business Value - -8 - -## Priority - -2 -""" - parsed = _parse_refinement_output_fields(refined) - body_markdown = parsed.get("body_markdown") or "" - - assert "User-facing summary." in body_markdown - assert "## Notes" in body_markdown - assert "Keep this narrative note." in body_markdown - assert "## Dependencies" in body_markdown - assert "- Team A" in body_markdown - assert "## Story Points" not in body_markdown - assert "## Business Value" not in body_markdown - assert "## Priority" not in body_markdown - - def test_preserves_uppercase_heading_style_notes_and_dependencies_in_body_markdown(self) -> None: - """Uppercase heading variants should still be preserved in writeback body.""" - refined = """ -User-facing summary. - -## ACCEPTANCE CRITERIA - -- first - -## NOTES - -Keep this uppercase narrative note. - -## DEPENDENCIES - -- Team B - -## STORY POINTS - -3 - -## BUSINESS VALUE - -5 - -## PRIORITY - -1 -""" - parsed = _parse_refinement_output_fields(refined) - body_markdown = parsed.get("body_markdown") or "" - - assert "User-facing summary." in body_markdown - assert "## Notes" in body_markdown - assert "Keep this uppercase narrative note." in body_markdown - assert "## Dependencies" in body_markdown - assert "- Team B" in body_markdown - assert "## STORY POINTS" not in body_markdown - assert "## BUSINESS VALUE" not in body_markdown - assert "## PRIORITY" not in body_markdown - - def test_label_only_output_without_description_does_not_fallback_to_raw_payload(self) -> None: - """Label-only output without Description should not leak raw labels into body/description.""" - refined = """ -Acceptance Criteria: -- [ ] Keep canonical writeback fields - -Story Points: -3 - -Business Value: -5 - -Priority: -2 - -Provider: -ado -""" - parsed = _parse_refinement_output_fields(refined) - body_markdown = parsed.get("body_markdown") or "" - - assert parsed.get("description") in (None, "") - assert parsed["acceptance_criteria"] == "- [ ] Keep canonical writeback fields" - assert parsed["story_points"] == 3 - assert parsed["business_value"] == 5 - assert parsed["priority"] == 2 - assert "Acceptance Criteria:" not in body_markdown - assert "Story Points:" not in body_markdown - assert "Business Value:" not in body_markdown - assert "Priority:" not in body_markdown - assert "Provider:" not in body_markdown - - def test_mixed_heading_and_inline_notes_preserves_description_before_notes(self) -> None: - """Mixed heading + inline label format should keep narrative before inline notes.""" - refined = """ -## Work Item Properties / Metadata - -- Story Points: 5 -- Business Value: 8 -- Priority: 2 -- Provider: ado - -## Description - -The API call currently fails for valid users. -This context must stay in description. - -**Notes**: -Investigate token refresh path. - -## Acceptance Criteria - -- [ ] Successful login for valid users -""" - parsed = _parse_refinement_output_fields(refined) - body_markdown = parsed.get("body_markdown") or "" - assert "The API call currently fails for valid users." in body_markdown - assert "This context must stay in description." in body_markdown - assert "## Notes" in body_markdown - assert "Investigate token refresh path." in body_markdown - assert "**Notes**:" not in body_markdown - assert body_markdown.count("Investigate token refresh path.") == 1 - assert "## Acceptance Criteria" not in body_markdown - assert parsed["acceptance_criteria"] == "- [ ] Successful login for valid users" - - def test_label_notes_with_internal_heading_keeps_heading_content(self) -> None: - """Notes label payload may contain internal headings that must be preserved.""" - refined = """ -Description: -Short summary. - -Notes: -Context details before heading. -## Risks -- API rate-limit -Follow-up mitigation note. - -Dependencies: -- Team Platform -""" - parsed = _parse_refinement_output_fields(refined) - body_markdown = parsed.get("body_markdown") or "" - - assert "## Notes" in body_markdown - assert "Context details before heading." in body_markdown - assert "## Risks" in body_markdown - assert "- API rate-limit" in body_markdown - assert "Follow-up mitigation note." in body_markdown - assert "## Dependencies" in body_markdown - - -class TestBuildRefineExportContent: - """Tests for refine export content rendering.""" - - def test_refine_export_includes_comments_when_available(self) -> None: - """Refine export includes comment annotations by default when available.""" - item = BacklogItem( - id="42", - provider="ado", - url="https://dev.azure.com/org/project/_workitems/edit/42", - title="Story", - body_markdown="Body text", - state="Active", - assignees=[], - ) - content = _build_refine_export_content( - adapter="ado", - items=[item], - comments_by_item_id={"42": ["Comment A", "Comment B"]}, - ) - assert "Comments (annotations)" in content - assert "Comment A" in content - assert "Comment B" in content - assert "## Copilot Instructions" in content - assert "must not include this instruction block" in content - assert "Preserve all original requirements, scope, and technical details" in content - - def test_refine_export_omits_comments_section_when_none(self) -> None: - """Refine export omits comments section when no comments exist for item.""" - item = BacklogItem( - id="42", - provider="ado", - url="https://dev.azure.com/org/project/_workitems/edit/42", - title="Story", - body_markdown="Body text", - state="Active", - assignees=[], - ) - content = _build_refine_export_content(adapter="ado", items=[item], comments_by_item_id={}) - assert "Comments (annotations)" not in content - - def test_refine_export_places_instructions_before_first_item(self) -> None: - """Instruction block appears before exported item sections.""" - item = BacklogItem( - id="42", - provider="ado", - url="https://dev.azure.com/org/project/_workitems/edit/42", - title="Story", - body_markdown="Body text", - state="Active", - assignees=[], - ) - content = _build_refine_export_content(adapter="ado", items=[item], comments_by_item_id={}) - assert content.index("## Copilot Instructions") < content.index("## Item 1:") - - def test_refine_export_marks_id_as_mandatory_for_import(self) -> None: - """Export guidance should state ID is required and immutable for import.""" - item = BacklogItem( - id="42", - provider="ado", - url="https://dev.azure.com/org/project/_workitems/edit/42", - title="Story", - body_markdown="Body text", - state="Active", - assignees=[], - ) - content = _build_refine_export_content(adapter="ado", items=[item], comments_by_item_id={}) - assert "**ID** is mandatory" in content - assert "must remain unchanged" in content - assert "Do NOT summarize, shorten, or drop details" in content - assert "Template Execution Rules (mandatory)" in content - - def test_refine_export_includes_template_guidance_for_items(self) -> None: - """Export includes template guidance similar to interactive prompts.""" - item = BacklogItem( - id="42", - provider="github", - url="https://github.com/org/repo/issues/42", - title="Story", - body_markdown="Body text", - state="open", - assignees=[], - ) - content = _build_refine_export_content( - adapter="github", - items=[item], - comments_by_item_id={}, - template_guidance_by_item_id={ - "42": { - "template_id": "enabler_v1", - "name": "Enabler", - "description": "Enabler work template", - "required_sections": ["Objective", "Technical Approach", "Success Criteria"], - "optional_sections": ["Dependencies", "Risks", "Timeline"], - } - }, - ) - assert "**Target Template**:" in content - assert "**Required Sections**:" in content - assert "**Optional Sections**:" in content - - -class TestRefineCommentWindowResolution: - """Tests for refine preview/export comment-window semantics.""" - - def test_refine_preview_defaults_to_last_two_comments(self) -> None: - """Preview uses last two comments when no explicit window flags are provided.""" - first, last = _resolve_refine_preview_comment_window(first_comments=None, last_comments=None) - assert first is None - assert last == 2 - - def test_refine_preview_respects_first_comments_override(self) -> None: - """Preview honors --first-comments when provided.""" - first, last = _resolve_refine_preview_comment_window(first_comments=5, last_comments=None) - assert first == 5 - assert last is None - - def test_refine_preview_respects_last_comments_override(self) -> None: - """Preview honors --last-comments when provided.""" - first, last = _resolve_refine_preview_comment_window(first_comments=None, last_comments=4) - assert first is None - assert last == 4 - - def test_refine_export_always_uses_full_comment_history(self) -> None: - """Export ignores preview comment-window flags and always requests full comments.""" - first, last = _resolve_refine_export_comment_window(first_comments=5, last_comments=None) - assert first is None - assert last is None - - first_2, last_2 = _resolve_refine_export_comment_window(first_comments=None, last_comments=3) - assert first_2 is None - assert last_2 is None - - -class TestRefineImportFromTmp: - """Tests for refine --import-from-tmp behavior.""" - - @patch("specfact_backlog.backlog.commands._fetch_backlog_items") - def test_import_from_tmp_fails_when_no_parsed_ids_match_fetched_items( - self, mock_fetch_items: MagicMock, tmp_path - ) -> None: - """Import should fail fast when refined IDs do not match fetched backlog items.""" - mock_fetch_items.return_value = [ - BacklogItem( - id="1", - provider="github", - url="https://github.com/org/repo/issues/1", - title="Issue 1", - body_markdown="Original body", - state="open", - assignees=[], - ) - ] - - refined_file = tmp_path / "refined.md" - refined_file.write_text( - """ -## Item 1: Edited Title - -**ID**: 999 -**URL**: https://github.com/org/repo/issues/999 -**State**: open -**Provider**: github - -**Body**: -```markdown -Refined body -``` -""".strip(), - encoding="utf-8", - ) - - result = runner.invoke( - backlog_app, - [ - "refine", - "github", - "--repo-owner", - "org", - "--repo-name", - "repo", - "--import-from-tmp", - "--tmp-file", - str(refined_file), - ], - ) - - assert result.exit_code != 0 - assert "None of the refined item IDs matched fetched backlog items" in result.stdout - - @patch("specfact_backlog.backlog.commands._fetch_backlog_items") - def test_import_from_tmp_fails_when_refined_body_is_significantly_shortened( - self, mock_fetch_items: MagicMock, tmp_path - ) -> None: - """Import should fail when tmp refinement drops substantial original detail.""" - mock_fetch_items.return_value = [ - BacklogItem( - id="1", - provider="github", - url="https://github.com/org/repo/issues/1", - title="Issue 1", - body_markdown=( - "Implement OAuth login with PKCE, refresh-token rotation, role-based checks, " - "audit logging, and token-expiry handling." - ), - state="open", - assignees=[], - ) - ] - - refined_file = tmp_path / "refined.md" - refined_file.write_text( - """ -## Item 1: Edited Title - -**ID**: 1 -**URL**: https://github.com/org/repo/issues/1 -**State**: open -**Provider**: github - -**Body**: -```markdown -Implement login support. -``` -""".strip(), - encoding="utf-8", - ) - - result = runner.invoke( - backlog_app, - [ - "refine", - "github", - "--repo-owner", - "org", - "--repo-name", - "repo", - "--import-from-tmp", - "--tmp-file", - str(refined_file), - ], - ) - - assert result.exit_code != 0 - assert "appears to drop important detail" in result.stdout - - -class TestRefinePreviewCommentUx: - """Tests for refine preview comment progress and block rendering.""" - - def test_build_comment_fetch_progress_description_includes_position(self) -> None: - """Progress message uses n/m indicator while fetching comments.""" - message = _build_comment_fetch_progress_description(3, 66, "123") - assert "3/66" in message - assert "123" in message - assert "Fetching issue" in message - - def test_build_refine_preview_comment_panels_returns_panels(self) -> None: - """Preview comments are rendered as panel blocks for clear scoping.""" - panels = _build_refine_preview_comment_panels(["first comment", "second comment"]) - assert len(panels) == 2 - assert all(isinstance(panel, Panel) for panel in panels) - - def test_build_refine_preview_comment_empty_panel_returns_panel(self) -> None: - """Preview shows explicit hint when no comments are found.""" - panel = _build_refine_preview_comment_empty_panel() - assert isinstance(panel, Panel) - - -class TestRefineIssueWindow: - """Tests for refine first/last issue window controls.""" - - @staticmethod - def _item(id_: str) -> BacklogItem: - return BacklogItem( - id=id_, - provider="github", - url=f"https://github.com/org/repo/issues/{id_}", - title=f"Item {id_}", - body_markdown="Body", - state="open", - assignees=[], - ) - - def test_apply_issue_window_first_issues(self) -> None: - items = [self._item("3"), self._item("1"), self._item("2")] - result = _apply_issue_window(items, first_issues=2, last_issues=None) - assert [i.id for i in result] == ["1", "2"] - - def test_apply_issue_window_last_issues(self) -> None: - items = [self._item("3"), self._item("1"), self._item("2")] - result = _apply_issue_window(items, first_issues=None, last_issues=2) - assert [i.id for i in result] == ["2", "3"] - - def test_apply_issue_window_rejects_both_first_and_last(self) -> None: - items = [self._item("1")] - try: - _apply_issue_window(items, first_issues=1, last_issues=1) - except ValueError as exc: - assert "--first-issues" in str(exc) - return - raise AssertionError("Expected ValueError when both first_issues and last_issues are set") - - -class TestItemNeedsRefinement: - """Tests for _item_needs_refinement helper.""" - - def test_needs_refinement_when_missing_sections(self) -> None: - """Item needs refinement when required sections are missing.""" - registry = TemplateRegistry() - registry.register_template( - BacklogTemplate( - template_id="user-story", - name="User Story", - description="", - required_sections=["As a", "I want", "Acceptance Criteria"], - ) - ) - detector = TemplateDetector(registry) - item = BacklogItem( - id="1", - provider="github", - url="https://github.com/org/repo/issues/1", - title="Story", - body_markdown="As a user I want...", - state="open", - assignees=[], - ) - assert _item_needs_refinement(item, detector, registry, None, "github", None, None) is True - - def test_does_not_need_refinement_when_high_confidence_no_missing(self) -> None: - """Item does not need refinement when confidence >= 0.8 and no missing fields.""" - registry = TemplateRegistry() - registry.register_template( - BacklogTemplate( - template_id="user-story", - name="User Story", - description="", - required_sections=["Acceptance Criteria"], - ) - ) - detector = TemplateDetector(registry) - item = BacklogItem( - id="2", - provider="github", - url="https://github.com/org/repo/issues/2", - title="Story", - body_markdown="As a user I want X.\n\n## Acceptance Criteria\n- [ ] Done", - state="open", - assignees=[], - ) - result = _item_needs_refinement(item, detector, registry, None, "github", None, None) - assert result is False - - def test_ado_does_not_require_story_points_heading_in_body_sections(self) -> None: - """ADO items should not be forced to include Story Points as markdown body heading.""" - registry = TemplateRegistry() - registry.register_template( - BacklogTemplate( - template_id="scrum-story", - name="Scrum Story", - description="", - required_sections=["As a", "I want", "So that", "Acceptance Criteria", "Story Points"], - ) - ) - detector = TemplateDetector(registry) - item = BacklogItem( - id="10", - provider="ado", - url="https://dev.azure.com/org/project/_workitems/edit/10", - title="User Story", - body_markdown="## As a\nuser\n\n## I want\nvalue\n\n## So that\nbenefit\n\n## Acceptance Criteria\n- [ ] done", - state="Active", - assignees=[], - story_points=5, - ) - # Should be considered already refined if no missing non-structured required sections. - assert _item_needs_refinement(item, detector, registry, None, "ado", "scrum", None) is False - - -class TestResolveTargetTemplateForRefineItem: - """Tests for template steering helper used by backlog refine.""" - - def test_ado_user_story_type_prefers_user_story_template(self) -> None: - """ADO User Story/PBI items should prefer user_story_v1 over generic ado_work_item_v1.""" - registry = TemplateRegistry() - registry.register_template( - BacklogTemplate( - template_id="ado_work_item_v1", - name="ADO Work Item", - description="", - provider="ado", - required_sections=["Description", "Acceptance Criteria"], - ) - ) - registry.register_template( - BacklogTemplate( - template_id="user_story_v1", - name="User Story", - description="", - required_sections=["As a", "I want", "So that", "Acceptance Criteria"], - ) - ) - detector = TemplateDetector(registry) - item = BacklogItem( - id="42", - provider="ado", - url="https://dev.azure.com/org/project/_workitems/edit/42", - title="User Story: refine mapping", - body_markdown="## Description\n\nBody\n\n## Acceptance Criteria\n- [ ] one", - state="Active", - assignees=[], - work_item_type="User Story", - ) - - resolved = _resolve_target_template_for_refine_item( - item, - detector=detector, - registry=registry, - template_id=None, - normalized_adapter="ado", - normalized_framework=None, - normalized_persona=None, - ) - - assert resolved is not None - assert resolved.template_id == "user_story_v1" - - def test_github_story_tag_prefers_user_story_template(self) -> None: - """GitHub story-labeled items should prefer user_story_v1 over generic enabler templates.""" - registry = TemplateRegistry() - registry.register_template( - BacklogTemplate( - template_id="enabler_v1", - name="Enabler", - description="", - provider="github", - required_sections=["Description"], - ) - ) - registry.register_template( - BacklogTemplate( - template_id="user_story_v1", - name="User Story", - description="", - provider=None, - required_sections=["As a", "I want", "So that", "Acceptance Criteria"], - ) - ) - detector = TemplateDetector(registry) - item = BacklogItem( - id="77", - provider="github", - url="https://github.com/o/r/issues/77", - title="Story: improve login flow", - body_markdown="## Description\n\nImprove flow", - state="open", - assignees=[], - tags=["story"], - ) - - resolved = _resolve_target_template_for_refine_item( - item, - detector=detector, - registry=registry, - template_id=None, - normalized_adapter="github", - normalized_framework=None, - normalized_persona=None, - ) - - assert resolved is not None - assert resolved.template_id == "user_story_v1" - - def test_non_story_item_does_not_recurse_and_resolves_detected_template(self) -> None: - """Non-story items should resolve without recursive fallback loops.""" - registry = TemplateRegistry() - registry.register_template( - BacklogTemplate( - template_id="enabler_v1", - name="Enabler", - description="", - provider="github", - required_sections=["Description"], - ) - ) - detector = TemplateDetector(registry) - item = BacklogItem( - id="88", - provider="github", - url="https://github.com/o/r/issues/88", - title="Improve pipeline", - body_markdown="## Description\n\nImprove pipeline execution.", - state="open", - assignees=[], - tags=["enhancement"], - ) - - resolved = _resolve_target_template_for_refine_item( - item, - detector=detector, - registry=registry, - template_id=None, - normalized_adapter="github", - normalized_framework=None, - normalized_persona=None, - ) - - assert resolved is not None - assert resolved.template_id == "enabler_v1" diff --git a/tests/unit/commands/test_backlog_daily.py b/tests/unit/commands/test_backlog_daily.py index 80bf80cf..88efbaac 100644 --- a/tests/unit/commands/test_backlog_daily.py +++ b/tests/unit/commands/test_backlog_daily.py @@ -22,7 +22,6 @@ import re from datetime import UTC, datetime -from pathlib import Path from unittest.mock import MagicMock import click @@ -911,24 +910,3 @@ def test_summarize_prompt_normalizes_html_comments_to_markdown(self) -> None: assert "<div" not in content assert "<br" not in content assert "&" not in content - - -class TestBacklogDailyPromptFile: - """Prompt file specfact.backlog-daily.md exists and has expected sections (22.2).""" - - def test_backlog_daily_prompt_file_exists(self) -> None: - """resources/prompts/specfact.backlog-daily.md exists.""" - repo_root = Path(__file__).resolve().parent.parent.parent.parent - prompt_path = repo_root / "resources" / "prompts" / "specfact.backlog-daily.md" - assert prompt_path.is_file(), f"Expected prompt file at {prompt_path}" - - def test_backlog_daily_prompt_contains_expected_sections(self) -> None: - """Prompt file contains purpose, story-by-story, discussion notes as comments.""" - repo_root = Path(__file__).resolve().parent.parent.parent.parent - prompt_path = repo_root / "resources" / "prompts" / "specfact.backlog-daily.md" - if not prompt_path.is_file(): - return - text = prompt_path.read_text(encoding="utf-8") - assert "daily" in text.lower() or "standup" in text.lower() - assert "story" in text.lower() or "item" in text.lower() - assert "comment" in text.lower() or "discussion" in text.lower() diff --git a/tests/unit/commands/test_backlog_filtering.py b/tests/unit/commands/test_backlog_filtering.py deleted file mode 100644 index 70fe5b43..00000000 --- a/tests/unit/commands/test_backlog_filtering.py +++ /dev/null @@ -1,428 +0,0 @@ -""" -Unit tests for backlog filtering functionality. - -Tests the _apply_filters function with various filter combinations, -including open/closed GitHub issues. -""" - -from __future__ import annotations - -from typing import Any - -import pytest -from beartype import beartype - - -pytest.importorskip("specfact_backlog.backlog.commands") -from specfact_backlog.backlog.commands import _apply_filters - -from specfact_cli.backlog.converter import convert_github_issue_to_backlog_item -from specfact_cli.models.backlog_item import BacklogItem - - -@pytest.fixture -def sample_github_issues() -> list[dict[str, Any]]: - """Create sample GitHub issues for testing.""" - return [ - { - "number": 1, - "html_url": "https://github.com/test/repo/issues/1", - "title": "Open issue with feature label", - "body": "This is an open issue", - "state": "open", - "assignees": [{"login": "dev1"}], - "labels": [{"name": "feature"}, {"name": "enhancement"}], - "milestone": None, - }, - { - "number": 2, - "html_url": "https://github.com/test/repo/issues/2", - "title": "Closed issue with bug label", - "body": "This is a closed issue", - "state": "closed", - "assignees": [{"login": "dev2"}], - "labels": [{"name": "bug"}], - "milestone": None, - }, - { - "number": 3, - "html_url": "https://github.com/test/repo/issues/3", - "title": "Open issue assigned to dev1", - "body": "Another open issue", - "state": "open", - "assignees": [{"login": "dev1"}], - "labels": [{"name": "task"}], - "milestone": None, - }, - { - "number": 4, - "html_url": "https://github.com/test/repo/issues/4", - "title": "Closed issue with feature label", - "body": "Closed feature issue", - "state": "closed", - "assignees": [{"login": "dev2"}], - "labels": [{"name": "feature"}], - "milestone": None, - }, - { - "number": 5, - "html_url": "https://github.com/test/repo/issues/5", - "title": "Open issue with sprint milestone", - "body": "Issue in sprint", - "state": "open", - "assignees": [], - "labels": [{"name": "enhancement"}], - "milestone": {"title": "Sprint 1"}, - }, - { - "number": 6, - "html_url": "https://github.com/test/repo/issues/6", - "title": "Closed issue with release milestone", - "body": "Issue in release", - "state": "closed", - "assignees": [], - "labels": [{"name": "bug"}], - "milestone": {"title": "v1.0.0"}, - }, - ] - - -@pytest.fixture -def backlog_items(sample_github_issues: list[dict[str, Any]]) -> list[BacklogItem]: - """Convert GitHub issues to BacklogItem instances.""" - return [convert_github_issue_to_backlog_item(issue) for issue in sample_github_issues] - - -class TestBacklogFiltering: - """Test backlog filtering functionality.""" - - @beartype - def test_filter_by_state_open(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by open state.""" - filtered = _apply_filters(backlog_items, state="open") - - assert len(filtered) == 3 - assert all(item.state.lower() == "open" for item in filtered) - assert all(item.id in ["1", "3", "5"] for item in filtered) - - @beartype - def test_filter_by_state_closed(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by closed state.""" - filtered = _apply_filters(backlog_items, state="closed") - - assert len(filtered) == 3 - assert all(item.state.lower() == "closed" for item in filtered) - assert all(item.id in ["2", "4", "6"] for item in filtered) - - @beartype - def test_filter_by_labels_single(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by single label.""" - filtered = _apply_filters(backlog_items, labels=["feature"]) - - assert len(filtered) == 2 - assert all("feature" in [tag.lower() for tag in item.tags] for item in filtered) - assert all(item.id in ["1", "4"] for item in filtered) - - @beartype - def test_filter_by_labels_multiple(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by multiple labels (OR logic - any label matches).""" - filtered = _apply_filters(backlog_items, labels=["feature", "bug"]) - - assert len(filtered) == 4 - assert all( - any(label in [tag.lower() for tag in item.tags] for label in ["feature", "bug"]) for item in filtered - ) - assert all(item.id in ["1", "2", "4", "6"] for item in filtered) - - @beartype - def test_filter_by_assignee(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by assignee.""" - filtered = _apply_filters(backlog_items, assignee="dev1") - - assert len(filtered) == 2 - assert all("dev1" in [a.lower() for a in item.assignees] for item in filtered) - assert all(item.id in ["1", "3"] for item in filtered) - - @beartype - def test_filter_by_assignee_ado_displayname(self) -> None: - """Test filtering ADO items by displayName.""" - from specfact_cli.backlog.converter import convert_ado_work_item_to_backlog_item - - # Create ADO items with different assignee identifiers - ado_items = [ - convert_ado_work_item_to_backlog_item( - { - "id": 1, - "url": "https://dev.azure.com/org/project/_apis/wit/workitems/1", - "fields": { - "System.Title": "Item 1", - "System.Description": "", - "System.State": "New", - "System.AssignedTo": {"displayName": "John Doe", "uniqueName": "john@example.com"}, - }, - } - ), - convert_ado_work_item_to_backlog_item( - { - "id": 2, - "url": "https://dev.azure.com/org/project/_apis/wit/workitems/2", - "fields": { - "System.Title": "Item 2", - "System.Description": "", - "System.State": "New", - "System.AssignedTo": {"displayName": "Jane Smith", "uniqueName": "jane@example.com"}, - }, - } - ), - ] - - # Filter by displayName - filtered = _apply_filters(ado_items, assignee="John Doe") - assert len(filtered) == 1 - assert filtered[0].id == "1" - - @beartype - def test_filter_by_assignee_ado_unique_name(self) -> None: - """Test filtering ADO items by uniqueName.""" - from specfact_cli.backlog.converter import convert_ado_work_item_to_backlog_item - - ado_items = [ - convert_ado_work_item_to_backlog_item( - { - "id": 1, - "url": "https://dev.azure.com/org/project/_apis/wit/workitems/1", - "fields": { - "System.Title": "Item 1", - "System.Description": "", - "System.State": "New", - "System.AssignedTo": {"displayName": "John Doe", "uniqueName": "john@example.com"}, - }, - } - ), - ] - - # Filter by uniqueName (should match even though displayName is different) - filtered = _apply_filters(ado_items, assignee="john@example.com") - assert len(filtered) == 1 - assert filtered[0].id == "1" - - @beartype - def test_filter_by_assignee_ado_mail(self) -> None: - """Test filtering ADO items by mail field.""" - from specfact_cli.backlog.converter import convert_ado_work_item_to_backlog_item - - ado_items = [ - convert_ado_work_item_to_backlog_item( - { - "id": 1, - "url": "https://dev.azure.com/org/project/_apis/wit/workitems/1", - "fields": { - "System.Title": "Item 1", - "System.Description": "", - "System.State": "New", - "System.AssignedTo": { - "displayName": "Bob Johnson", - "uniqueName": "bob@example.com", - "mail": "bob.johnson@example.com", - }, - }, - } - ), - ] - - # Filter by mail field - filtered = _apply_filters(ado_items, assignee="bob.johnson@example.com") - assert len(filtered) == 1 - assert filtered[0].id == "1" - - @beartype - def test_filter_by_assignee_case_insensitive(self) -> None: - """Test that assignee filtering is case-insensitive.""" - from specfact_cli.backlog.converter import convert_ado_work_item_to_backlog_item - - ado_items = [ - convert_ado_work_item_to_backlog_item( - { - "id": 1, - "url": "https://dev.azure.com/org/project/_apis/wit/workitems/1", - "fields": { - "System.Title": "Item 1", - "System.Description": "", - "System.State": "New", - "System.AssignedTo": {"displayName": "John Doe", "uniqueName": "john@example.com"}, - }, - } - ), - ] - - # Filter with different case - filtered = _apply_filters(ado_items, assignee="JOHN DOE") - assert len(filtered) == 1 - assert filtered[0].id == "1" - - @beartype - def test_filter_by_assignee_unassigned(self) -> None: - """Test filtering for unassigned items.""" - from specfact_cli.backlog.converter import convert_ado_work_item_to_backlog_item - - ado_items = [ - convert_ado_work_item_to_backlog_item( - { - "id": 1, - "url": "https://dev.azure.com/org/project/_apis/wit/workitems/1", - "fields": { - "System.Title": "Item 1", - "System.Description": "", - "System.State": "New", - # No System.AssignedTo field - }, - } - ), - convert_ado_work_item_to_backlog_item( - { - "id": 2, - "url": "https://dev.azure.com/org/project/_apis/wit/workitems/2", - "fields": { - "System.Title": "Item 2", - "System.Description": "", - "System.State": "New", - "System.AssignedTo": {"displayName": "John Doe"}, - }, - } - ), - ] - - # Filter by assignee should only return assigned items - filtered = _apply_filters(ado_items, assignee="John Doe") - assert len(filtered) == 1 - assert filtered[0].id == "2" - - @beartype - def test_filter_by_sprint(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by sprint.""" - filtered = _apply_filters(backlog_items, sprint="Sprint 1") - - assert len(filtered) == 1 - assert filtered[0].id == "5" - assert filtered[0].sprint == "Sprint 1" - - @beartype - def test_filter_by_release(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by release.""" - filtered = _apply_filters(backlog_items, release="v1.0.0") - - assert len(filtered) == 1 - assert filtered[0].id == "6" - assert filtered[0].release == "v1.0.0" - - @beartype - def test_filter_combined_state_and_labels(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by state AND labels.""" - filtered = _apply_filters(backlog_items, state="open", labels=["feature"]) - - assert len(filtered) == 1 - assert filtered[0].id == "1" - assert filtered[0].state.lower() == "open" - assert "feature" in [tag.lower() for tag in filtered[0].tags] - - @beartype - def test_filter_combined_state_and_assignee(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by state AND assignee.""" - filtered = _apply_filters(backlog_items, state="closed", assignee="dev2") - - assert len(filtered) == 2 - assert all(item.state.lower() == "closed" for item in filtered) - assert all("dev2" in [a.lower() for a in item.assignees] for item in filtered) - assert all(item.id in ["2", "4"] for item in filtered) - - @beartype - def test_filter_combined_all_filters(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering with all filters combined.""" - filtered = _apply_filters( - backlog_items, state="open", labels=["enhancement"], assignee="dev1", sprint="Sprint 1" - ) - - # No item matches all criteria simultaneously - assert len(filtered) == 0 - - @beartype - def test_filter_case_insensitive_state(self, backlog_items: list[BacklogItem]) -> None: - """Test that state filtering is case-insensitive.""" - filtered_upper = _apply_filters(backlog_items, state="OPEN") - filtered_lower = _apply_filters(backlog_items, state="open") - filtered_mixed = _apply_filters(backlog_items, state="OpEn") - - assert len(filtered_upper) == len(filtered_lower) == len(filtered_mixed) == 3 - - @beartype - def test_filter_case_insensitive_labels(self, backlog_items: list[BacklogItem]) -> None: - """Test that label filtering is case-insensitive.""" - filtered_upper = _apply_filters(backlog_items, labels=["FEATURE"]) - filtered_lower = _apply_filters(backlog_items, labels=["feature"]) - filtered_mixed = _apply_filters(backlog_items, labels=["FeAtUrE"]) - - assert len(filtered_upper) == len(filtered_lower) == len(filtered_mixed) == 2 - - @beartype - def test_filter_case_insensitive_assignee(self, backlog_items: list[BacklogItem]) -> None: - """Test that assignee filtering is case-insensitive.""" - filtered_upper = _apply_filters(backlog_items, assignee="DEV1") - filtered_lower = _apply_filters(backlog_items, assignee="dev1") - filtered_mixed = _apply_filters(backlog_items, assignee="DeV1") - - assert len(filtered_upper) == len(filtered_lower) == len(filtered_mixed) == 2 - - @beartype - def test_filter_no_filters_returns_all(self, backlog_items: list[BacklogItem]) -> None: - """Test that no filters returns all items.""" - filtered = _apply_filters(backlog_items) - - assert len(filtered) == len(backlog_items) == 6 - - @beartype - def test_filter_empty_list(self) -> None: - """Test filtering empty list.""" - filtered = _apply_filters([], state="open", labels=["feature"]) - - assert len(filtered) == 0 - - @beartype - def test_filter_nonexistent_label(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by non-existent label.""" - filtered = _apply_filters(backlog_items, labels=["nonexistent"]) - - assert len(filtered) == 0 - - @beartype - def test_filter_nonexistent_assignee(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by non-existent assignee.""" - filtered = _apply_filters(backlog_items, assignee="nonexistent") - - assert len(filtered) == 0 - - @beartype - def test_filter_nonexistent_state(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering by non-existent state.""" - filtered = _apply_filters(backlog_items, state="nonexistent") - - assert len(filtered) == 0 - - @beartype - def test_filter_open_issues_with_feature_label(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering open issues with feature label (real-world scenario).""" - filtered = _apply_filters(backlog_items, state="open", labels=["feature"]) - - assert len(filtered) == 1 - assert filtered[0].id == "1" - assert filtered[0].state.lower() == "open" - assert "feature" in [tag.lower() for tag in filtered[0].tags] - - @beartype - def test_filter_closed_issues_with_bug_label(self, backlog_items: list[BacklogItem]) -> None: - """Test filtering closed issues with bug label (real-world scenario).""" - filtered = _apply_filters(backlog_items, state="closed", labels=["bug"]) - - assert len(filtered) == 2 - assert all(item.state.lower() == "closed" for item in filtered) - assert all("bug" in [tag.lower() for tag in item.tags] for item in filtered) - assert all(item.id in ["2", "6"] for item in filtered) diff --git a/tests/unit/specfact_cli/test_module_migration_compatibility.py b/tests/unit/specfact_cli/test_module_migration_compatibility.py index 7f43c32d..52297a1f 100644 --- a/tests/unit/specfact_cli/test_module_migration_compatibility.py +++ b/tests/unit/specfact_cli/test_module_migration_compatibility.py @@ -19,7 +19,6 @@ LEGACY_SHIM_TO_MODULE: dict[str, str] = { "analyze": "analyze", "auth": "auth", - "backlog_commands": "backlog", "contract_cmd": "contract", "drift": "drift", "enforce": "enforce", diff --git a/tests/unit/test_backlog_module_ownership_cleanup.py b/tests/unit/test_backlog_module_ownership_cleanup.py new file mode 100644 index 00000000..d800906e --- /dev/null +++ b/tests/unit/test_backlog_module_ownership_cleanup.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from pathlib import Path + +from specfact_cli.registry import module_packages +from specfact_cli.utils.ide_setup import SPECFACT_COMMANDS + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_core_repo_no_longer_ships_backlog_owned_command_surfaces() -> None: + """Core should not retain backlog-owned command packages or shims after migration.""" + forbidden_paths = [ + REPO_ROOT / "modules" / "backlog-core", + REPO_ROOT / "src" / "specfact_cli" / "commands" / "backlog_commands.py", + REPO_ROOT / "src" / "specfact_cli" / "groups" / "backlog_group.py", + ] + + existing = [str(path.relative_to(REPO_ROOT)) for path in forbidden_paths if path.exists()] + assert not existing, f"Core still ships backlog-owned command surfaces: {existing}" + + +def test_core_prompt_export_surface_excludes_backlog_prompts_and_templates() -> None: + """Backlog prompts/templates must no longer ship from core resources.""" + forbidden_paths = [ + REPO_ROOT / "resources" / "prompts" / "specfact.backlog-add.md", + REPO_ROOT / "resources" / "prompts" / "specfact.backlog-daily.md", + REPO_ROOT / "resources" / "prompts" / "specfact.backlog-refine.md", + REPO_ROOT / "resources" / "prompts" / "specfact.sync-backlog.md", + REPO_ROOT / "resources" / "templates" / "backlog", + ] + + existing = [str(path.relative_to(REPO_ROOT)) for path in forbidden_paths if path.exists()] + assert not existing, f"Core still exports backlog prompt/template assets: {existing}" + + forbidden_prompt_ids = { + "specfact.backlog-add", + "specfact.backlog-daily", + "specfact.backlog-refine", + "specfact.sync-backlog", + } + leaked_prompt_ids = sorted(forbidden_prompt_ids.intersection(SPECFACT_COMMANDS)) + assert not leaked_prompt_ids, f"Core IDE prompt list still includes backlog prompt ids: {leaked_prompt_ids}" + + +def test_backlog_duplicate_overlap_tolerance_is_not_required() -> None: + """Registry merge logic should not special-case split backlog ownership anymore.""" + duplicate_tolerance = getattr(module_packages, "_is_expected_duplicate_extension", None) + if duplicate_tolerance is None: + return + + tolerated = [ + pair + for pair in [ + ("backlog", "daily"), + ("backlog", "refine"), + ("backlog", "init-config"), + ("backlog", "map-fields"), + ("backlog auth", "github"), + ] + if duplicate_tolerance("nold-ai/specfact-backlog", pair[0], pair[1]) + ] + assert not tolerated, f"Backlog duplicate overlap is still specially tolerated: {tolerated}" diff --git a/tests/unit/utils/test_ide_setup.py b/tests/unit/utils/test_ide_setup.py index 4569916b..29294c35 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -238,8 +238,8 @@ def test_copy_templates_overwrites_with_force(self, tmp_path): content = (cursor_dir / "specfact.01-import.md").read_text() assert "New Content" in content or "# New Content" in content - def test_copy_templates_includes_backlog_add_prompt_when_template_exists(self, tmp_path): - """Copy flow should install backlog-add prompt into IDE target when template exists.""" + def test_copy_templates_only_installs_prompt_ids_from_core_command_list(self, tmp_path): + """Core export only copies prompts explicitly listed in the core command registry.""" templates_dir = tmp_path / "resources" / "prompts" templates_dir.mkdir(parents=True) (templates_dir / "specfact.backlog-add.md").write_text( @@ -248,10 +248,13 @@ def test_copy_templates_includes_backlog_add_prompt_when_template_exists(self, t copied_files, _settings_path = copy_templates_to_ide(tmp_path, "cursor", templates_dir, force=True) - assert any(path.name == "specfact.backlog-add.md" for path in copied_files) - assert (tmp_path / ".cursor" / "commands" / "specfact.backlog-add.md").exists() + assert not any(path.name == "specfact.backlog-add.md" for path in copied_files) + assert not (tmp_path / ".cursor" / "commands" / "specfact.backlog-add.md").exists() -def test_specfact_commands_includes_backlog_add_prompt() -> None: - """IDE setup command list includes backlog-add prompt template.""" - assert "specfact.backlog-add" in SPECFACT_COMMANDS +def test_specfact_commands_excludes_backlog_prompt_ids() -> None: + """Core IDE setup command list excludes backlog-owned prompt ids.""" + assert "specfact.backlog-add" not in SPECFACT_COMMANDS + assert "specfact.backlog-daily" not in SPECFACT_COMMANDS + assert "specfact.backlog-refine" not in SPECFACT_COMMANDS + assert "specfact.sync-backlog" not in SPECFACT_COMMANDS From 29995b5aef395b5511f689186caf7e33bdefbb17 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:31:41 +0100 Subject: [PATCH 2/3] fix: align CI marketplace validation paths --- .../OWNERSHIP_MATRIX.md | 22 +++++++++---------- ...test_command_package_runtime_validation.py | 5 +++++ .../unit/registry/test_marketplace_client.py | 3 +++ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/openspec/changes/backlog-module-ownership-cleanup/OWNERSHIP_MATRIX.md b/openspec/changes/backlog-module-ownership-cleanup/OWNERSHIP_MATRIX.md index cbb65c8c..1cd996b6 100644 --- a/openspec/changes/backlog-module-ownership-cleanup/OWNERSHIP_MATRIX.md +++ b/openspec/changes/backlog-module-ownership-cleanup/OWNERSHIP_MATRIX.md @@ -15,11 +15,11 @@ ### Module ownership that already exists -- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/module-package.yaml` +- `../specfact-cli-modules/packages/specfact-backlog/module-package.yaml` - Registers `nold-ai/specfact-backlog` as the official backlog bundle. -- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/commands.py` +- `../specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/commands.py` - Owns `backlog daily`, `backlog refine`, `backlog init-config`, `backlog map-fields`, ceremony aliases, and auth flows. -- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/policy_engine/commands.py` +- `../specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/policy_engine/commands.py` - Owns policy-engine command behavior for the backlog bundle. ## Prompt And Template Ownership @@ -43,9 +43,9 @@ ### Module-side prompt/template ownership that already exists -- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/templates/registry.py` -- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/template_detector.py` -- `/home/dom/git/nold-ai/specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/mappers/template_config.py` +- `../specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/templates/registry.py` +- `../specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/template_detector.py` +- `../specfact-cli-modules/packages/specfact-backlog/src/specfact_backlog/backlog/mappers/template_config.py` ## Runtime Helpers @@ -96,11 +96,11 @@ ### Module tests that should own backlog feature behavior after migration -- `/home/dom/git/nold-ai/specfact-cli-modules/tests/unit/specfact_backlog/test_map_fields_command.py` -- `/home/dom/git/nold-ai/specfact-cli-modules/tests/unit/specfact_backlog/test_auth_commands.py` -- `/home/dom/git/nold-ai/specfact-cli-modules/tests/unit/specfact_backlog/test_refine_adapter_contract.py` -- `/home/dom/git/nold-ai/specfact-cli-modules/tests/integration/specfact_backlog/test_command_apps.py` -- `/home/dom/git/nold-ai/specfact-cli-modules/tests/e2e/specfact_backlog/test_help_smoke.py` +- `../specfact-cli-modules/tests/unit/specfact_backlog/test_map_fields_command.py` +- `../specfact-cli-modules/tests/unit/specfact_backlog/test_auth_commands.py` +- `../specfact-cli-modules/tests/unit/specfact_backlog/test_refine_adapter_contract.py` +- `../specfact-cli-modules/tests/integration/specfact_backlog/test_command_apps.py` +- `../specfact-cli-modules/tests/e2e/specfact_backlog/test_help_smoke.py` ## Docs Impact diff --git a/tests/integration/test_command_package_runtime_validation.py b/tests/integration/test_command_package_runtime_validation.py index b053ceb3..86ad2d3d 100644 --- a/tests/integration/test_command_package_runtime_validation.py +++ b/tests/integration/test_command_package_runtime_validation.py @@ -13,7 +13,12 @@ def _resolve_modules_repo() -> Path: + configured = os.environ.get("SPECFACT_MODULES_REPO", "").strip() + if configured: + return Path(configured).expanduser() + candidates = [ + REPO_ROOT / "specfact-cli-modules", REPO_ROOT.parent / "specfact-cli-modules", REPO_ROOT.parents[2] / "specfact-cli-modules", ] diff --git a/tests/unit/registry/test_marketplace_client.py b/tests/unit/registry/test_marketplace_client.py index 8cbde775..9c35b087 100644 --- a/tests/unit/registry/test_marketplace_client.py +++ b/tests/unit/registry/test_marketplace_client.py @@ -29,6 +29,9 @@ class _Result: try: monkeypatch.delenv("SPECFACT_MODULES_BRANCH", raising=False) + monkeypatch.delenv("GITHUB_HEAD_REF", raising=False) + monkeypatch.delenv("GITHUB_BASE_REF", raising=False) + monkeypatch.delenv("GITHUB_REF", raising=False) monkeypatch.setenv("GITHUB_REF_NAME", "main") monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: _Result()) assert get_modules_branch() == "main" From 318627c51ef2388c939932a3c6b8368ea8b4a588 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:46:36 +0100 Subject: [PATCH 3/3] test: stabilize command audit validation and add command-surface change --- openspec/CHANGE_ORDER.md | 1 + .../design.md | 66 +++++++++++++++++++ .../proposal.md | 48 ++++++++++++++ .../bundle-command-surface-alignment/spec.md | 49 ++++++++++++++ .../tasks.md | 32 +++++++++ ...test_command_package_runtime_validation.py | 3 + 6 files changed, 199 insertions(+) create mode 100644 openspec/changes/module-migration-10-bundle-command-surface-alignment/design.md create mode 100644 openspec/changes/module-migration-10-bundle-command-surface-alignment/proposal.md create mode 100644 openspec/changes/module-migration-10-bundle-command-surface-alignment/specs/bundle-command-surface-alignment/spec.md create mode 100644 openspec/changes/module-migration-10-bundle-command-surface-alignment/tasks.md diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index f10c6b3f..0804abb1 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -101,6 +101,7 @@ These are derived extensions of the same 2026-02-15 plan and are required to ope | module-migration | 07 | module-migration-07-test-migration-cleanup | [#339](https://github.com/nold-ai/specfact-cli/issues/339) | migration-03 phase 20 handoff; migration-04 and migration-05 residual specfact-cli test debt | | module-migration | 08 | module-migration-08-release-suite-stabilization | TBD | module-migration-03/04/06/07 merged; residual release-suite regressions after migration merge | | module-migration | 09 | backlog-module-ownership-cleanup | TBD | module-migration-06; backlog-core-07; cli-val-07 findings | +| module-migration | 10 | module-migration-10-bundle-command-surface-alignment | [#385](https://github.com/nold-ai/specfact-cli/issues/385) | module-migration-02 ✅; module-migration-06/07 baseline; cli-val-07 findings | | init-ide | 01 | init-ide-prompt-source-selection | TBD | backlog-module-ownership-cleanup | | backlog-auth | 01 | backlog-auth-01-backlog-auth-commands | TBD | module-migration-03 (central auth interface in core; auth removed from core) | diff --git a/openspec/changes/module-migration-10-bundle-command-surface-alignment/design.md b/openspec/changes/module-migration-10-bundle-command-surface-alignment/design.md new file mode 100644 index 00000000..8e6df316 --- /dev/null +++ b/openspec/changes/module-migration-10-bundle-command-surface-alignment/design.md @@ -0,0 +1,66 @@ +# Design: Bundle Command Surface Alignment + +## Context + +The official bundles in `specfact-cli-modules` are currently mounted at the top-level grouped roots (`project`, `spec`, `code`, `backlog`, `govern`). However, runtime validation and release-content verification show that some documented grouped command paths are not actually reachable from the installed bundle roots even though source implementations exist in subpackages such as: + +- `specfact_project/import_cmd/commands.py` +- `specfact_project/plan/commands.py` +- `specfact_spec/generate/commands.py` +- `specfact_spec/contract/commands.py` + +The mismatch can come from one or more of: + +1. bundle root apps not mounting intended subgroup apps +2. module manifests exposing only a coarse root while nested Typer apps are never registered under that root +3. docs/release pages describing intended command paths that are not part of the shipped bundle runtime + +## Goals + +- Make the shipped bundle command tree and the documented grouped CLI surface agree. +- Preserve grouped command UX (`specfact project ...`, `specfact spec ...`) as the primary CLI contract. +- Extend runtime validation so missing documented command paths fail before release docs drift again. + +## Non-Goals + +- Redesign the grouped command model. +- Reintroduce removed flat command shims. +- Fold backlog ownership cleanup into this scope. + +## Design Decisions + +### 1. Treat documented grouped commands as a release contract + +For commands documented in README/docs/release content, we treat their grouped CLI path as part of the shipped release surface. If the implementation exists and is intended, the bundle must expose it. If it is not intended, the docs must be corrected in the same change. + +### 2. Fix runtime exposure at the bundle layer, not in core + +The correct fix location is the bundle command tree in `specfact-cli-modules`, not ad hoc core shims in `specfact-cli`. Core should validate and consume the official bundle surface, not patch around missing bundle registration. + +### 3. Separate runtime fixes from docs-only removals + +Each missing command path discovered by the audit must be classified as one of: + +- `public-runtime`: implemented and intended, must be exposed by the bundle +- `docs-only-drift`: not actually part of the shipped command surface, docs must change +- `owner-decision-required`: ambiguous public intent, block release-doc updates until resolved + +This avoids silently deleting user-facing commands just because they are currently unreachable. + +### 4. Extend runtime validation to assert documented grouped paths + +The existing command-package runtime validation should include an explicit set of documented grouped command paths that must resolve in an installed-bundle environment. This closes the current gap where source code exists but the release surface is not actually mounted. + +## Implementation Outline + +1. Inventory documented grouped command paths from README/docs/release-content pages that claim `v0.40.x` support. +2. Compare those paths against installed official bundle help/runtime behavior. +3. For `public-runtime` paths, patch the affected bundle root apps/manifests so the subgroup commands are mounted. +4. For `docs-only-drift` paths, update release docs in `specfact-cli` so shipped examples are truthful. +5. Add targeted regression tests plus command-audit assertions for the documented grouped paths. + +## Risks + +- Some source-only commands may depend on assumptions that never held in installed-bundle mode, so mounting them can expose follow-on runtime bugs. +- Docs and runtime may have diverged in multiple repos (`specfact-cli` and `specfact-cli-modules`), so the implementation may require coordinated PRs. +- Public examples in blog/website content may need synchronized updates outside the repo docs set. diff --git a/openspec/changes/module-migration-10-bundle-command-surface-alignment/proposal.md b/openspec/changes/module-migration-10-bundle-command-surface-alignment/proposal.md new file mode 100644 index 00000000..3e6b44a5 --- /dev/null +++ b/openspec/changes/module-migration-10-bundle-command-surface-alignment/proposal.md @@ -0,0 +1,48 @@ +# Change: Bundle Command Surface Alignment + +## Why + + + +The released `v0.40.x` CLI command surface does not fully match the documented grouped command API. Several command implementations exist inside official bundles such as `specfact-project` and `specfact-spec`, but the installed bundle command trees do not expose the documented paths at runtime. This creates a release-quality gap: README/site/blog examples describe commands that fail with `No such command ...`, while users can only discover the subset currently mounted by the bundle root apps. + +This must be treated as a product/runtime alignment issue first, not just a docs refresh. Release documentation must match shipped behavior, and intended commands that are already implemented in bundle source should either be exposed correctly or removed from the documented surface explicitly. + +## What Changes + + + +- Audit the documented grouped command surface against the installed official bundle command trees for `project`, `spec`, and any other affected bundles. +- Expose implemented-but-unreachable commands through the official bundle registration/runtime surface where they are intended to be public. +- Mark any non-shipped or intentionally unsupported command forms as removed from the documented release surface instead of leaving them implied by source-only code. +- Add regression coverage that validates the released bundle command trees include the documented grouped CLI paths. +- Align README/docs/release-content references so `v0.40.x` documentation reflects the actual shipped command surface. + +## Capabilities +### New Capabilities + +- `bundle-command-surface-alignment`: Official bundle command trees match the documented grouped CLI surface for shipped releases. + +## Acceptance Criteria +- Installed official bundles expose documented grouped commands such as `specfact project import from-code`, `specfact project plan ...`, and `specfact spec generate ...` when those commands are intended to be public in `v0.40.x`. +- If a command path is intentionally not part of the shipped runtime surface, README/docs/release content no longer describe it as available. +- Runtime validation fails when a documented grouped command path is missing from the installed official bundle command tree. +- Release-content examples no longer rely on slash-command-only fallbacks to paper over missing CLI registration. +- Validation evidence distinguishes between runtime-surface fixes and docs-only removals. + +## Dependencies +- `module-migration-02-bundle-extraction` established the official bundle packaging model. +- `module-migration-06-core-decoupling-cleanup` and `module-migration-07-test-migration-cleanup` established the current post-migration command ownership baseline. +- `cli-val-07-command-package-runtime-validation` provides the runtime audit harness that should be extended to catch this drift. +- `backlog-module-ownership-cleanup` remains a separate cleanup scope and should not absorb unrelated `project`/`spec` bundle-surface fixes. + + +--- + +## Source Tracking + +<!-- source_repo: nold-ai/specfact-cli --> +- **GitHub Issue**: #385 +- **Issue URL**: <https://github.com/nold-ai/specfact-cli/issues/385> +- **Last Synced Status**: proposed +- **Sanitized**: false \ No newline at end of file diff --git a/openspec/changes/module-migration-10-bundle-command-surface-alignment/specs/bundle-command-surface-alignment/spec.md b/openspec/changes/module-migration-10-bundle-command-surface-alignment/specs/bundle-command-surface-alignment/spec.md new file mode 100644 index 00000000..292e5ffe --- /dev/null +++ b/openspec/changes/module-migration-10-bundle-command-surface-alignment/specs/bundle-command-surface-alignment/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Documented Grouped Commands Must Resolve In Installed Official Bundles + +The system SHALL ensure that grouped CLI commands documented for a shipped release resolve in an environment where the corresponding official bundles are installed. + +#### Scenario: Documented project subgroup commands resolve + +- **GIVEN** the official `nold-ai/specfact-project` bundle is installed +- **WHEN** the user runs documented grouped command paths such as `specfact project import from-code --help` or `specfact project plan review --help` +- **THEN** the command path resolves successfully from the installed bundle runtime +- **AND** the help output reflects the mounted subgroup command rather than `No such command`. + +#### Scenario: Documented spec subgroup commands resolve + +- **GIVEN** the official `nold-ai/specfact-spec` bundle is installed +- **WHEN** the user runs documented grouped command paths such as `specfact spec generate contracts-prompt --help` or `specfact spec contract test --help` +- **THEN** the command path resolves successfully from the installed bundle runtime +- **AND** the installed bundle help tree exposes those subgroup commands. + +### Requirement: Release Documentation Must Not Promise Missing Grouped Commands + +The system SHALL keep release-facing documentation aligned with the actual shipped grouped command surface. + +#### Scenario: Unsupported path is removed from docs + +- **GIVEN** a grouped command path is not part of the shipped runtime surface for the current release +- **WHEN** release-facing docs are updated +- **THEN** that path is removed or corrected in README/docs/release content +- **AND** users are not told to run a command that fails at runtime. + +#### Scenario: Slash prompts do not hide missing CLI registration + +- **GIVEN** a grouped CLI command is documented as part of the release surface +- **WHEN** corresponding slash-command guidance exists +- **THEN** the docs may include the slash prompt as an IDE workflow aid +- **BUT NOT** as a substitute for a missing or unregistered CLI path. + +### Requirement: Runtime Validation Detects Documented Command Drift + +The system SHALL fail validation when a documented grouped command path is missing from the installed official bundle command tree. + +#### Scenario: Missing documented grouped command fails validation + +- **GIVEN** a documented grouped command inventory for the shipped release +- **AND** the official bundle install/runtime validation environment +- **WHEN** a documented grouped command path is not mounted by the installed bundle +- **THEN** validation fails with the missing command path and owning bundle id +- **AND** the report distinguishes this from help-only or docs-only command coverage. diff --git a/openspec/changes/module-migration-10-bundle-command-surface-alignment/tasks.md b/openspec/changes/module-migration-10-bundle-command-surface-alignment/tasks.md new file mode 100644 index 00000000..feb98966 --- /dev/null +++ b/openspec/changes/module-migration-10-bundle-command-surface-alignment/tasks.md @@ -0,0 +1,32 @@ +# Tasks + +## 1. Audit And Classify The Missing Command Paths + +- [ ] 1.1 Build a documented grouped-command inventory for the affected `project` and `spec` surfaces from README/docs/release-content references. +- [ ] 1.2 Verify each documented path against the installed official bundle runtime. +- [ ] 1.3 Classify each missing path as `public-runtime`, `docs-only-drift`, or `owner-decision-required`. + +## 2. Update Specs And Failing Tests First + +- [ ] 2.1 Add or update spec deltas for documented grouped command-path parity. +- [ ] 2.2 Add failing regression tests for the currently missing public-runtime paths. +- [ ] 2.3 Record pre-implementation failing evidence in `TDD_EVIDENCE.md`. + +## 3. Fix Bundle Runtime Exposure + +- [ ] 3.1 Patch the affected official bundles in `specfact-cli-modules` so intended grouped subcommands are mounted and reachable. +- [ ] 3.2 Verify the installed-bundle command tree exposes the intended grouped paths end-to-end. +- [ ] 3.3 Avoid adding new core CLI shims to compensate for bundle registration gaps. + +## 4. Align Release Documentation + +- [ ] 4.1 Update README/docs/release-facing examples in `specfact-cli` for any `docs-only-drift` paths. +- [ ] 4.2 Ensure public docs do not describe missing grouped commands as available in `v0.40.x`. +- [ ] 4.3 Capture any website/blog follow-up that must be synchronized outside this repo. + +## 5. Validate And Record Evidence + +- [ ] 5.1 Re-run targeted command-surface tests for the fixed paths. +- [ ] 5.2 Extend or re-run command-package runtime validation for the documented grouped paths. +- [ ] 5.3 Record post-implementation passing evidence in `TDD_EVIDENCE.md`. +- [ ] 5.4 Run `openspec validate module-migration-10-bundle-command-surface-alignment --strict`. diff --git a/tests/integration/test_command_package_runtime_validation.py b/tests/integration/test_command_package_runtime_validation.py index 86ad2d3d..2255d324 100644 --- a/tests/integration/test_command_package_runtime_validation.py +++ b/tests/integration/test_command_package_runtime_validation.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +import pytest + from specfact_cli.validation.command_audit import build_command_audit_cases, official_marketplace_module_ids @@ -66,6 +68,7 @@ def _run_cli(env: dict[str, str], *argv: str, cwd: Path | None = None) -> subpro ) +@pytest.mark.timeout(300) def test_command_audit_help_cases_execute_cleanly_in_temp_home(tmp_path: Path) -> None: home_dir = tmp_path / "home" home_dir.mkdir(parents=True, exist_ok=True)