From faaf707900bade366ff4ab0b851c7d5b839bf87b Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Mon, 9 Feb 2026 20:02:58 +0000 Subject: [PATCH 1/8] feat: add cxtm.yaml support for enriching issue bodies with test case definitions Add --cxtm-file option to process-test-requirements command that extracts test case metadata (title, robot_file, git_commit_sha, etc.) from cxtm.yaml and embeds it as a structured YAML block in the GitHub issue body. This enables downstream systems (like tac-agent-worker) to reliably extract test case identifiers and robot file paths from the issue, rather than relying on manually-written YAML in the test requirement definition. Changes: - Add CXTMConfiguration, CXTMTestCaseGroup, CXTMTestCase models - Add utils/cxtm.py with load/find/format functions - Add --cxtm-file CLI option to process-test-requirements - Update issue template with "Test Case Definition" section - Integrate cxtm lookup in render_issue_body_for_test_case() Co-Authored-By: Claude Opus 4.6 --- github_ops_manager/configuration/cli.py | 9 + github_ops_manager/schemas/tac.py | 38 +++- .../synchronize/test_requirements.py | 43 ++++- .../templates/tac_issues_body.j2 | 10 ++ github_ops_manager/utils/cxtm.py | 166 ++++++++++++++++++ 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 github_ops_manager/utils/cxtm.py diff --git a/github_ops_manager/configuration/cli.py b/github_ops_manager/configuration/cli.py index 46ff124..697a771 100644 --- a/github_ops_manager/configuration/cli.py +++ b/github_ops_manager/configuration/cli.py @@ -256,6 +256,14 @@ def process_test_requirements_cli( "If provided, migrates existing issue/PR metadata to test_cases.yaml before processing.", ), ] = None, + cxtm_file: Annotated[ + Path | None, + Option( + envvar="CXTM_FILE", + help="Path to cxtm.yaml file. If provided, enriches issue bodies with test case " + "definitions from cxtm.yaml (title, robot_file, git_commit_sha, etc.).", + ), + ] = None, ) -> None: """Process test requirements directly from test_cases.yaml files. @@ -404,6 +412,7 @@ async def run_processing() -> dict: catalog_repo_url=catalog_repo_url, issue_template_path=issue_template, issue_labels=parsed_labels, + cxtm_file_path=cxtm_file, ) results = asyncio.run(run_processing()) diff --git a/github_ops_manager/schemas/tac.py b/github_ops_manager/schemas/tac.py index 7673429..eab2a54 100644 --- a/github_ops_manager/schemas/tac.py +++ b/github_ops_manager/schemas/tac.py @@ -1,6 +1,6 @@ """Schemas/models for Testing as Code constructs.""" -from pydantic import BaseModel +from pydantic import BaseModel, Field class TestingAsCodeCommand(BaseModel): @@ -33,3 +33,39 @@ class TestingAsCodeTestCaseDefinitions(BaseModel): """Pydantic model for a list of Testing as Code test case definitions.""" test_cases: list[TestingAsCodeTestCaseDefinition] + + +# ============================================================================= +# CXTM Configuration Models (cxtm.yaml) +# ============================================================================= +# These models represent the test case definitions stored in cxtm.yaml, +# which is the authoritative source for test case metadata used by tac-tools. + + +class CXTMTestCase(BaseModel): + """A single test case within a test case group in cxtm.yaml.""" + + identifier: str = Field(description="Unique identifier within the group (e.g., '0')") + title: str = Field(description="Human-readable test case title") + robot_file: str | None = Field(default=None, description="Path to the Robot Framework test file") + git_url: str | None = Field(default=None, description="URL of the git repository containing the test") + git_commit_sha: str | None = Field(default=None, description="Git commit SHA or branch name") + parameters_file: str | None = Field(default=None, description="Path to parameters JSON file") + parameters: dict | None = Field(default=None, description="Inline test parameters") + + +class CXTMTestCaseGroup(BaseModel): + """A test case group in cxtm.yaml containing one or more test cases.""" + + name: str = Field(description="Name of the test case group") + test_cases: list[CXTMTestCase] = Field(default_factory=list, description="List of test cases in this group") + + +class CXTMConfiguration(BaseModel): + """Root model for cxtm.yaml configuration file.""" + + cxta_version: str | None = Field(default=None, description="CXTA version") + cxtm_project_id: str | None = Field(default=None, description="CXTM project ID") + git_branch: str | None = Field(default=None, description="Default git branch") + git_url: str | None = Field(default=None, description="Project repository URL") + test_case_groups: list[CXTMTestCaseGroup] = Field(default_factory=list, description="List of test case groups") diff --git a/github_ops_manager/synchronize/test_requirements.py b/github_ops_manager/synchronize/test_requirements.py index 155e401..37e56f4 100644 --- a/github_ops_manager/synchronize/test_requirements.py +++ b/github_ops_manager/synchronize/test_requirements.py @@ -503,6 +503,7 @@ def render_issue_body_for_test_case( test_case: dict[str, Any], template: jinja2.Template, max_body_length: int | None = None, + cxtm_config: Any | None = None, ) -> str: """Render issue body for a test case using the template. @@ -510,6 +511,7 @@ def render_issue_body_for_test_case( test_case: Test case dictionary with all fields template: Jinja2 template for issue body max_body_length: Optional max length for issue body (truncates outputs if needed) + cxtm_config: Optional CXTMConfiguration for enriching with test case definitions Returns: Rendered issue body string @@ -528,6 +530,23 @@ def render_issue_body_for_test_case( "commands": commands_as_dicts, } + # Look up test case definition in cxtm.yaml and add to render context + if cxtm_config: + from github_ops_manager.utils.cxtm import find_test_case_by_title, format_test_case_yaml_block + + title = test_case.get("title", "") + group, cxtm_test_case = find_test_case_by_title(cxtm_config, title) + if group and cxtm_test_case: + render_context["test_case_definition"] = format_test_case_yaml_block(group, cxtm_test_case) + logger.debug( + "Found cxtm.yaml test case definition", + title=title, + group_name=group.name, + test_case_id=cxtm_test_case.identifier, + ) + else: + logger.debug("No cxtm.yaml test case definition found", title=title) + # Apply truncation to command outputs if max_body_length is specified if max_body_length is not None: render_context = truncate_data_dict_outputs(render_context, max_body_length) @@ -551,6 +570,7 @@ async def process_test_requirements( issue_template_path: Path | None = None, issue_labels: list[str] | None = None, max_body_length: int = DEFAULT_MAX_ISSUE_BODY_LENGTH, + cxtm_file_path: Path | None = None, ) -> dict[str, Any]: """Process all test requirements: create issues and PRs as needed. @@ -569,6 +589,7 @@ async def process_test_requirements( issue_template_path: Optional path to Jinja2 template for issue bodies issue_labels: Optional list of labels to apply to issues max_body_length: Maximum issue body length (truncates outputs if exceeded) + cxtm_file_path: Optional path to cxtm.yaml for enriching issues with test case definitions Returns: Summary dict with counts and results @@ -579,6 +600,26 @@ async def process_test_requirements( base_directory=str(base_directory), ) + # Load cxtm.yaml configuration if provided + cxtm_config = None + if cxtm_file_path: + from github_ops_manager.utils.cxtm import load_cxtm_configuration + + try: + cxtm_config = load_cxtm_configuration(cxtm_file_path) + logger.info( + "Loaded cxtm.yaml configuration", + cxtm_file=str(cxtm_file_path), + test_case_groups=len(cxtm_config.test_case_groups), + ) + except Exception as e: + logger.warning( + "Failed to load cxtm.yaml, proceeding without test case definitions", + cxtm_file=str(cxtm_file_path), + error=str(e), + ) + cxtm_config = None + # Load issue body template if provided template = None if issue_template_path: @@ -666,7 +707,7 @@ async def process_test_requirements( # This renders the issue template with full test requirement details. if template: try: - issue_body = render_issue_body_for_test_case(test_case, template, max_body_length=max_body_length) + issue_body = render_issue_body_for_test_case(test_case, template, max_body_length=max_body_length, cxtm_config=cxtm_config) except Exception as e: logger.error("Failed to render issue body", title=title, error=str(e)) results["errors"].append(f"Failed to render issue body for {title}: {e}") diff --git a/github_ops_manager/templates/tac_issues_body.j2 b/github_ops_manager/templates/tac_issues_body.j2 index 23db324..9d95b65 100644 --- a/github_ops_manager/templates/tac_issues_body.j2 +++ b/github_ops_manager/templates/tac_issues_body.j2 @@ -1,3 +1,13 @@ +{% if test_case_definition is defined and test_case_definition %} +## Test Case Definition + +```yaml +{{ test_case_definition }} +``` + +{% endif %} +## Test Requirement + {% if purpose is defined %} **Purpose**: {{ purpose }} diff --git a/github_ops_manager/utils/cxtm.py b/github_ops_manager/utils/cxtm.py new file mode 100644 index 0000000..13d11cb --- /dev/null +++ b/github_ops_manager/utils/cxtm.py @@ -0,0 +1,166 @@ +"""Utility functions for parsing and working with cxtm.yaml files. + +The cxtm.yaml file is the authoritative source for test case definitions +used by tac-tools. This module provides functions to extract test case +metadata that can be embedded into GitHub issues for traceability. +""" + +import logging +import re +from pathlib import Path + +from github_ops_manager.schemas.tac import ( + CXTMConfiguration, + CXTMTestCase, + CXTMTestCaseGroup, +) +from github_ops_manager.utils.yaml import load_yaml_file + +logger = logging.getLogger(__name__) + + +def load_cxtm_configuration(cxtm_file_path: Path) -> CXTMConfiguration: + """Load and parse a cxtm.yaml configuration file. + + Args: + cxtm_file_path: Path to the cxtm.yaml file. + + Returns: + CXTMConfiguration object with parsed data. + + Raises: + FileNotFoundError: If the file doesn't exist. + ValidationError: If the file has invalid structure. + """ + if not cxtm_file_path.exists(): + raise FileNotFoundError(f"cxtm.yaml not found: {cxtm_file_path.absolute()}") + + yaml_content = load_yaml_file(cxtm_file_path) + return CXTMConfiguration.model_validate(yaml_content) + + +def find_test_case_by_title( + cxtm_config: CXTMConfiguration, + title: str, + fuzzy_match: bool = True, +) -> tuple[CXTMTestCaseGroup | None, CXTMTestCase | None]: + """Find a test case in cxtm.yaml by its title. + + Searches through all test case groups to find a test case matching + the given title. Supports both exact and fuzzy matching. + + Args: + cxtm_config: Parsed cxtm.yaml configuration. + title: Title to search for (e.g., "[NX-OS] Verify Module Serial Number"). + fuzzy_match: If True, performs case-insensitive partial matching. + + Returns: + Tuple of (test_case_group, test_case) if found, (None, None) otherwise. + """ + # Normalize title for comparison + normalized_title = title.strip().lower() if fuzzy_match else title.strip() + + for group in cxtm_config.test_case_groups: + # Check group name + group_name = group.name.strip().lower() if fuzzy_match else group.name.strip() + + if fuzzy_match: + # Check if title matches group name or is contained within it + if normalized_title in group_name or group_name in normalized_title: + if group.test_cases: + return group, group.test_cases[0] + else: + if group_name == normalized_title: + if group.test_cases: + return group, group.test_cases[0] + + # Also check individual test case titles within the group + for test_case in group.test_cases: + tc_title = test_case.title.strip().lower() if fuzzy_match else test_case.title.strip() + if fuzzy_match: + if normalized_title in tc_title or tc_title in normalized_title: + return group, test_case + else: + if tc_title == normalized_title: + return group, test_case + + return None, None + + +def find_test_case_by_robot_file( + cxtm_config: CXTMConfiguration, + robot_file_path: str, +) -> tuple[CXTMTestCaseGroup | None, CXTMTestCase | None]: + """Find a test case in cxtm.yaml by its robot file path. + + Args: + cxtm_config: Parsed cxtm.yaml configuration. + robot_file_path: Path to the robot file (can be partial path). + + Returns: + Tuple of (test_case_group, test_case) if found, (None, None) otherwise. + """ + # Normalize path for comparison + normalized_path = robot_file_path.strip().lower() + + for group in cxtm_config.test_case_groups: + for test_case in group.test_cases: + if test_case.robot_file: + tc_robot_file = test_case.robot_file.strip().lower() + # Check for exact match or if one contains the other + if normalized_path in tc_robot_file or tc_robot_file in normalized_path: + return group, test_case + + return None, None + + +def extract_test_name_from_title(title: str) -> str: + """Extract the test name from a title in format '[OS] Test Name'. + + Args: + title: Full title string (e.g., "[NX-OS] Verify Module Serial Number"). + + Returns: + The test name portion (e.g., "Verify Module Serial Number"). + """ + pattern = re.compile(r"^\[(?P[^\]]+)\]\s+(?P.+)$") + match = pattern.match(title.strip()) + if match: + return match.group("test_name") + return title.strip() + + +def format_test_case_yaml_block( + group: CXTMTestCaseGroup, + test_case: CXTMTestCase, +) -> str: + """Format a test case definition as a YAML block for embedding in issues. + + Creates a structured YAML representation of the test case that can be + parsed by the webhook receiver to extract test case metadata. + + Args: + group: The test case group containing the test case. + test_case: The test case to format. + + Returns: + Formatted YAML string ready for embedding in an issue body. + """ + lines = [ + "test_case_group:", + f' name: "{group.name}"', + "test_case:", + f' identifier: "{test_case.identifier}"', + f' title: "{test_case.title}"', + ] + + if test_case.robot_file: + lines.append(f' robot_file: "{test_case.robot_file}"') + + if test_case.git_url: + lines.append(f' git_url: "{test_case.git_url}"') + + if test_case.git_commit_sha: + lines.append(f' git_commit_sha: "{test_case.git_commit_sha}"') + + return "\n".join(lines) From fa6edcc5e9a08f886f52a6e5e3baab5e034dcdf5 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Wed, 11 Feb 2026 18:09:43 +0000 Subject: [PATCH 2/8] feat: Add test_plan metadata to tracking issue YAML block Extract test_plan metadata (group_name and identifier) from test case metadata and include it in the tracking issue YAML block. This enables downstream tools (tac-agent-web, tac-agent-worker) to know the exact cxtm.yaml group name needed for `tac-tools scripts learn` commands. Changes: - Extract metadata.test_plan.group_name and metadata.test_plan.identifier in tracking_issues.py - Add test_plan_group_name and test_plan_identifier to the YAML block in tracking_issue.j2 (conditionally rendered if present) - Update CLI commands in template to use test_plan_group_name when available, falling back to test_case_title_clean for backward compat This is part of the self-healing test automation workflow where metadata flows from tac-tools synchronizer expand through tracking issues to the Claude agent worker. Co-Authored-By: Claude Opus 4.6 --- github_ops_manager/synchronize/tracking_issues.py | 7 +++++++ github_ops_manager/templates/tracking_issue.j2 | 10 ++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/github_ops_manager/synchronize/tracking_issues.py b/github_ops_manager/synchronize/tracking_issues.py index d448a79..5052cd9 100644 --- a/github_ops_manager/synchronize/tracking_issues.py +++ b/github_ops_manager/synchronize/tracking_issues.py @@ -132,12 +132,19 @@ async def create_tracking_issue_for_catalog_pr( else: commands_list.append(str(cmd)) + # Extract test_plan metadata injected by tac-tools synchronizer expand + test_plan_metadata = test_case.get("metadata", {}).get("test_plan", {}) + test_plan_group_name = test_plan_metadata.get("group_name", "") + test_plan_identifier = test_plan_metadata.get("identifier", "") + test_requirement = { "purpose": test_case.get("purpose", ""), "commands": commands_list, "pass_criteria": test_case.get("pass_criteria", ""), "sample_parameters": test_case.get("jobfile_parameters", ""), "parameters_to_parsed_data_mapping": test_case.get("jobfile_parameters_mapping", ""), + "test_plan_group_name": test_plan_group_name, + "test_plan_identifier": test_plan_identifier, } # Load and render the tracking issue template diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index 49fb3d2..b869b41 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -20,6 +20,12 @@ sample_parameters: | {{ test_requirement.sample_parameters | indent(2, first=True) }} parameters_to_parsed_data_mapping: | {{ test_requirement.parameters_to_parsed_data_mapping | indent(2, first=True) }} +{% if test_requirement.test_plan_group_name -%} +test_plan_group_name: "{{ test_requirement.test_plan_group_name }}" +{% endif -%} +{% if test_requirement.test_plan_identifier -%} +test_plan_identifier: "{{ test_requirement.test_plan_identifier }}" +{% endif -%} ``` ### Tasks @@ -33,12 +39,12 @@ parameters_to_parsed_data_mapping: | - [ ] Execute the following command to learn parameters and validate whether test case parameters are successfully inserted into the cxtm.yaml file. If any errors occur, troubleshoot and resolve them by editing the test automation script in the branch associated with the Catalog PR in the Catalog repository: ```bash - tac-tools scripts learn "{{ test_case_title_clean }}" + tac-tools scripts learn "{{ test_requirement.test_plan_group_name or test_case_title_clean }}" ``` - [ ] Execute the following command to run tests and validate whether test case parameters are successfully validated against the testbed. If any errors occur, troubleshoot and verify that they are not due to a legitimate issue with the testbed. If the testbed is healthy, edit the test automation script in the branch associated with the Catalog PR in the Catalog repository: ```bash - tac-tools scripts run "{{ test_case_title_clean }}" + tac-tools scripts run "{{ test_requirement.test_plan_group_name or test_case_title_clean }}" ``` - [ ] When the test automation script in the Catalog repository is in a known working condition, assign a Catalog reviewer to review the PR From 6efb97571261d281c3da795a584ce351d7a34cd3 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Wed, 11 Feb 2026 18:22:19 +0000 Subject: [PATCH 3/8] refactor: Rename test_plan metadata fields for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename fields to be more explicit: - test_plan_group_name → test_case_group_name - test_plan_identifier → test_case_identifier Add new field: - test_case_title Update tracking_issues.py to read from new YAML field names: - metadata.test_plan.test_case_group_name - metadata.test_plan.test_case_identifier - metadata.test_plan.test_case_title Co-Authored-By: Claude Opus 4.6 --- github_ops_manager/synchronize/tracking_issues.py | 10 ++++++---- github_ops_manager/templates/tracking_issue.j2 | 15 +++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/github_ops_manager/synchronize/tracking_issues.py b/github_ops_manager/synchronize/tracking_issues.py index 5052cd9..38194f5 100644 --- a/github_ops_manager/synchronize/tracking_issues.py +++ b/github_ops_manager/synchronize/tracking_issues.py @@ -134,8 +134,9 @@ async def create_tracking_issue_for_catalog_pr( # Extract test_plan metadata injected by tac-tools synchronizer expand test_plan_metadata = test_case.get("metadata", {}).get("test_plan", {}) - test_plan_group_name = test_plan_metadata.get("group_name", "") - test_plan_identifier = test_plan_metadata.get("identifier", "") + test_case_group_name = test_plan_metadata.get("test_case_group_name", "") + test_case_identifier = test_plan_metadata.get("test_case_identifier", "") + test_case_title_from_metadata = test_plan_metadata.get("test_case_title", "") test_requirement = { "purpose": test_case.get("purpose", ""), @@ -143,8 +144,9 @@ async def create_tracking_issue_for_catalog_pr( "pass_criteria": test_case.get("pass_criteria", ""), "sample_parameters": test_case.get("jobfile_parameters", ""), "parameters_to_parsed_data_mapping": test_case.get("jobfile_parameters_mapping", ""), - "test_plan_group_name": test_plan_group_name, - "test_plan_identifier": test_plan_identifier, + "test_case_group_name": test_case_group_name, + "test_case_identifier": test_case_identifier, + "test_case_title": test_case_title_from_metadata, } # Load and render the tracking issue template diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index b869b41..eca231a 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -20,11 +20,14 @@ sample_parameters: | {{ test_requirement.sample_parameters | indent(2, first=True) }} parameters_to_parsed_data_mapping: | {{ test_requirement.parameters_to_parsed_data_mapping | indent(2, first=True) }} -{% if test_requirement.test_plan_group_name -%} -test_plan_group_name: "{{ test_requirement.test_plan_group_name }}" +{% if test_requirement.test_case_group_name -%} +test_case_group_name: "{{ test_requirement.test_case_group_name }}" {% endif -%} -{% if test_requirement.test_plan_identifier -%} -test_plan_identifier: "{{ test_requirement.test_plan_identifier }}" +{% if test_requirement.test_case_identifier -%} +test_case_identifier: "{{ test_requirement.test_case_identifier }}" +{% endif -%} +{% if test_requirement.test_case_title -%} +test_case_title: "{{ test_requirement.test_case_title }}" {% endif -%} ``` @@ -39,12 +42,12 @@ test_plan_identifier: "{{ test_requirement.test_plan_identifier }}" - [ ] Execute the following command to learn parameters and validate whether test case parameters are successfully inserted into the cxtm.yaml file. If any errors occur, troubleshoot and resolve them by editing the test automation script in the branch associated with the Catalog PR in the Catalog repository: ```bash - tac-tools scripts learn "{{ test_requirement.test_plan_group_name or test_case_title_clean }}" + tac-tools scripts learn "{{ test_requirement.test_case_group_name or test_case_title_clean }}" ``` - [ ] Execute the following command to run tests and validate whether test case parameters are successfully validated against the testbed. If any errors occur, troubleshoot and verify that they are not due to a legitimate issue with the testbed. If the testbed is healthy, edit the test automation script in the branch associated with the Catalog PR in the Catalog repository: ```bash - tac-tools scripts run "{{ test_requirement.test_plan_group_name or test_case_title_clean }}" + tac-tools scripts run "{{ test_requirement.test_case_group_name or test_case_title_clean }}" ``` - [ ] When the test automation script in the Catalog repository is in a known working condition, assign a Catalog reviewer to review the PR From 43ff144a37ec4c23165c1240c151f696bcbc622a Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Wed, 11 Feb 2026 18:59:44 +0000 Subject: [PATCH 4/8] refactor: Nest test_plan fields under metadata.test_plan in YAML block Structure the test_plan metadata in the tracking issue YAML block to match the test_cases.yaml schema: ```yaml metadata: test_plan: test_case_group_name: "..." test_case_identifier: "..." test_case_title: "..." ``` This ensures consistency with how test requirements are structured in test_cases.yaml files. Co-Authored-By: Claude Opus 4.6 --- github_ops_manager/templates/tracking_issue.j2 | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index eca231a..a4a3269 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -20,14 +20,18 @@ sample_parameters: | {{ test_requirement.sample_parameters | indent(2, first=True) }} parameters_to_parsed_data_mapping: | {{ test_requirement.parameters_to_parsed_data_mapping | indent(2, first=True) }} +{% if test_requirement.test_case_group_name or test_requirement.test_case_identifier or test_requirement.test_case_title -%} +metadata: + test_plan: {% if test_requirement.test_case_group_name -%} -test_case_group_name: "{{ test_requirement.test_case_group_name }}" + test_case_group_name: "{{ test_requirement.test_case_group_name }}" {% endif -%} {% if test_requirement.test_case_identifier -%} -test_case_identifier: "{{ test_requirement.test_case_identifier }}" + test_case_identifier: "{{ test_requirement.test_case_identifier }}" {% endif -%} {% if test_requirement.test_case_title -%} -test_case_title: "{{ test_requirement.test_case_title }}" + test_case_title: "{{ test_requirement.test_case_title }}" +{% endif -%} {% endif -%} ``` From 66ba31328b3d0d151610e3ea5be9491ef9a343c3 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Wed, 11 Feb 2026 22:11:15 +0000 Subject: [PATCH 5/8] refactor: remove test_case_title_clean fallback from tracking issues Now that test_case_group_name is always populated by tac-tools synchronizer expand, the fallback to test_case_title_clean is no longer needed. The CLI commands in tracking issues now always use test_requirement.test_case_group_name directly. Co-Authored-By: Claude Opus 4.6 --- github_ops_manager/synchronize/tracking_issues.py | 5 ----- github_ops_manager/templates/tracking_issue.j2 | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/github_ops_manager/synchronize/tracking_issues.py b/github_ops_manager/synchronize/tracking_issues.py index 38194f5..e0151fd 100644 --- a/github_ops_manager/synchronize/tracking_issues.py +++ b/github_ops_manager/synchronize/tracking_issues.py @@ -113,10 +113,6 @@ async def create_tracking_issue_for_catalog_pr( test_case = test_cases[0] test_case_title = test_case.get("title", "Untitled Test Case") - # Strip OS tag from title for CLI commands (e.g., "[IOS-XE] Do Thing" -> "Do Thing") - # This matches the test case group name that will appear in cxtm.yaml - clean_title = strip_os_tag_from_title(test_case_title) - # Compute suggested project branch name from catalog branch suggested_branch = compute_project_branch_name(catalog_pr.head.ref) @@ -158,7 +154,6 @@ async def create_tracking_issue_for_catalog_pr( catalog_branch=catalog_pr.head.ref, suggested_project_branch=suggested_branch, test_case_title=test_case_title, # Original title with OS tag for display - test_case_title_clean=clean_title, # Clean title for CLI commands os_name=os_name.upper(), test_requirement=test_requirement, ) diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index a4a3269..77e5891 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -46,12 +46,12 @@ metadata: - [ ] Execute the following command to learn parameters and validate whether test case parameters are successfully inserted into the cxtm.yaml file. If any errors occur, troubleshoot and resolve them by editing the test automation script in the branch associated with the Catalog PR in the Catalog repository: ```bash - tac-tools scripts learn "{{ test_requirement.test_case_group_name or test_case_title_clean }}" + tac-tools scripts learn "{{ test_requirement.test_case_group_name }}" ``` - [ ] Execute the following command to run tests and validate whether test case parameters are successfully validated against the testbed. If any errors occur, troubleshoot and verify that they are not due to a legitimate issue with the testbed. If the testbed is healthy, edit the test automation script in the branch associated with the Catalog PR in the Catalog repository: ```bash - tac-tools scripts run "{{ test_requirement.test_case_group_name or test_case_title_clean }}" + tac-tools scripts run "{{ test_requirement.test_case_group_name }}" ``` - [ ] When the test automation script in the Catalog repository is in a known working condition, assign a Catalog reviewer to review the PR From e83e95cc6dec7185fd5ec4cb21252a1a3f5e9ae3 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 12 Feb 2026 00:21:17 +0000 Subject: [PATCH 6/8] Revert "merge: incorporate cxtm-yaml integration for --cxtm-file support" This reverts commit ef4a1214418d2c5ef1326015f1451bebf026e32d, reversing changes made to 66ba31328b3d0d151610e3ea5be9491ef9a343c3. --- github_ops_manager/configuration/cli.py | 9 - github_ops_manager/schemas/tac.py | 38 +--- .../synchronize/test_requirements.py | 43 +---- .../templates/tac_issues_body.j2 | 10 -- github_ops_manager/utils/cxtm.py | 166 ------------------ 5 files changed, 2 insertions(+), 264 deletions(-) delete mode 100644 github_ops_manager/utils/cxtm.py diff --git a/github_ops_manager/configuration/cli.py b/github_ops_manager/configuration/cli.py index 697a771..46ff124 100644 --- a/github_ops_manager/configuration/cli.py +++ b/github_ops_manager/configuration/cli.py @@ -256,14 +256,6 @@ def process_test_requirements_cli( "If provided, migrates existing issue/PR metadata to test_cases.yaml before processing.", ), ] = None, - cxtm_file: Annotated[ - Path | None, - Option( - envvar="CXTM_FILE", - help="Path to cxtm.yaml file. If provided, enriches issue bodies with test case " - "definitions from cxtm.yaml (title, robot_file, git_commit_sha, etc.).", - ), - ] = None, ) -> None: """Process test requirements directly from test_cases.yaml files. @@ -412,7 +404,6 @@ async def run_processing() -> dict: catalog_repo_url=catalog_repo_url, issue_template_path=issue_template, issue_labels=parsed_labels, - cxtm_file_path=cxtm_file, ) results = asyncio.run(run_processing()) diff --git a/github_ops_manager/schemas/tac.py b/github_ops_manager/schemas/tac.py index eab2a54..7673429 100644 --- a/github_ops_manager/schemas/tac.py +++ b/github_ops_manager/schemas/tac.py @@ -1,6 +1,6 @@ """Schemas/models for Testing as Code constructs.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel class TestingAsCodeCommand(BaseModel): @@ -33,39 +33,3 @@ class TestingAsCodeTestCaseDefinitions(BaseModel): """Pydantic model for a list of Testing as Code test case definitions.""" test_cases: list[TestingAsCodeTestCaseDefinition] - - -# ============================================================================= -# CXTM Configuration Models (cxtm.yaml) -# ============================================================================= -# These models represent the test case definitions stored in cxtm.yaml, -# which is the authoritative source for test case metadata used by tac-tools. - - -class CXTMTestCase(BaseModel): - """A single test case within a test case group in cxtm.yaml.""" - - identifier: str = Field(description="Unique identifier within the group (e.g., '0')") - title: str = Field(description="Human-readable test case title") - robot_file: str | None = Field(default=None, description="Path to the Robot Framework test file") - git_url: str | None = Field(default=None, description="URL of the git repository containing the test") - git_commit_sha: str | None = Field(default=None, description="Git commit SHA or branch name") - parameters_file: str | None = Field(default=None, description="Path to parameters JSON file") - parameters: dict | None = Field(default=None, description="Inline test parameters") - - -class CXTMTestCaseGroup(BaseModel): - """A test case group in cxtm.yaml containing one or more test cases.""" - - name: str = Field(description="Name of the test case group") - test_cases: list[CXTMTestCase] = Field(default_factory=list, description="List of test cases in this group") - - -class CXTMConfiguration(BaseModel): - """Root model for cxtm.yaml configuration file.""" - - cxta_version: str | None = Field(default=None, description="CXTA version") - cxtm_project_id: str | None = Field(default=None, description="CXTM project ID") - git_branch: str | None = Field(default=None, description="Default git branch") - git_url: str | None = Field(default=None, description="Project repository URL") - test_case_groups: list[CXTMTestCaseGroup] = Field(default_factory=list, description="List of test case groups") diff --git a/github_ops_manager/synchronize/test_requirements.py b/github_ops_manager/synchronize/test_requirements.py index 37e56f4..155e401 100644 --- a/github_ops_manager/synchronize/test_requirements.py +++ b/github_ops_manager/synchronize/test_requirements.py @@ -503,7 +503,6 @@ def render_issue_body_for_test_case( test_case: dict[str, Any], template: jinja2.Template, max_body_length: int | None = None, - cxtm_config: Any | None = None, ) -> str: """Render issue body for a test case using the template. @@ -511,7 +510,6 @@ def render_issue_body_for_test_case( test_case: Test case dictionary with all fields template: Jinja2 template for issue body max_body_length: Optional max length for issue body (truncates outputs if needed) - cxtm_config: Optional CXTMConfiguration for enriching with test case definitions Returns: Rendered issue body string @@ -530,23 +528,6 @@ def render_issue_body_for_test_case( "commands": commands_as_dicts, } - # Look up test case definition in cxtm.yaml and add to render context - if cxtm_config: - from github_ops_manager.utils.cxtm import find_test_case_by_title, format_test_case_yaml_block - - title = test_case.get("title", "") - group, cxtm_test_case = find_test_case_by_title(cxtm_config, title) - if group and cxtm_test_case: - render_context["test_case_definition"] = format_test_case_yaml_block(group, cxtm_test_case) - logger.debug( - "Found cxtm.yaml test case definition", - title=title, - group_name=group.name, - test_case_id=cxtm_test_case.identifier, - ) - else: - logger.debug("No cxtm.yaml test case definition found", title=title) - # Apply truncation to command outputs if max_body_length is specified if max_body_length is not None: render_context = truncate_data_dict_outputs(render_context, max_body_length) @@ -570,7 +551,6 @@ async def process_test_requirements( issue_template_path: Path | None = None, issue_labels: list[str] | None = None, max_body_length: int = DEFAULT_MAX_ISSUE_BODY_LENGTH, - cxtm_file_path: Path | None = None, ) -> dict[str, Any]: """Process all test requirements: create issues and PRs as needed. @@ -589,7 +569,6 @@ async def process_test_requirements( issue_template_path: Optional path to Jinja2 template for issue bodies issue_labels: Optional list of labels to apply to issues max_body_length: Maximum issue body length (truncates outputs if exceeded) - cxtm_file_path: Optional path to cxtm.yaml for enriching issues with test case definitions Returns: Summary dict with counts and results @@ -600,26 +579,6 @@ async def process_test_requirements( base_directory=str(base_directory), ) - # Load cxtm.yaml configuration if provided - cxtm_config = None - if cxtm_file_path: - from github_ops_manager.utils.cxtm import load_cxtm_configuration - - try: - cxtm_config = load_cxtm_configuration(cxtm_file_path) - logger.info( - "Loaded cxtm.yaml configuration", - cxtm_file=str(cxtm_file_path), - test_case_groups=len(cxtm_config.test_case_groups), - ) - except Exception as e: - logger.warning( - "Failed to load cxtm.yaml, proceeding without test case definitions", - cxtm_file=str(cxtm_file_path), - error=str(e), - ) - cxtm_config = None - # Load issue body template if provided template = None if issue_template_path: @@ -707,7 +666,7 @@ async def process_test_requirements( # This renders the issue template with full test requirement details. if template: try: - issue_body = render_issue_body_for_test_case(test_case, template, max_body_length=max_body_length, cxtm_config=cxtm_config) + issue_body = render_issue_body_for_test_case(test_case, template, max_body_length=max_body_length) except Exception as e: logger.error("Failed to render issue body", title=title, error=str(e)) results["errors"].append(f"Failed to render issue body for {title}: {e}") diff --git a/github_ops_manager/templates/tac_issues_body.j2 b/github_ops_manager/templates/tac_issues_body.j2 index 9d95b65..23db324 100644 --- a/github_ops_manager/templates/tac_issues_body.j2 +++ b/github_ops_manager/templates/tac_issues_body.j2 @@ -1,13 +1,3 @@ -{% if test_case_definition is defined and test_case_definition %} -## Test Case Definition - -```yaml -{{ test_case_definition }} -``` - -{% endif %} -## Test Requirement - {% if purpose is defined %} **Purpose**: {{ purpose }} diff --git a/github_ops_manager/utils/cxtm.py b/github_ops_manager/utils/cxtm.py deleted file mode 100644 index 13d11cb..0000000 --- a/github_ops_manager/utils/cxtm.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Utility functions for parsing and working with cxtm.yaml files. - -The cxtm.yaml file is the authoritative source for test case definitions -used by tac-tools. This module provides functions to extract test case -metadata that can be embedded into GitHub issues for traceability. -""" - -import logging -import re -from pathlib import Path - -from github_ops_manager.schemas.tac import ( - CXTMConfiguration, - CXTMTestCase, - CXTMTestCaseGroup, -) -from github_ops_manager.utils.yaml import load_yaml_file - -logger = logging.getLogger(__name__) - - -def load_cxtm_configuration(cxtm_file_path: Path) -> CXTMConfiguration: - """Load and parse a cxtm.yaml configuration file. - - Args: - cxtm_file_path: Path to the cxtm.yaml file. - - Returns: - CXTMConfiguration object with parsed data. - - Raises: - FileNotFoundError: If the file doesn't exist. - ValidationError: If the file has invalid structure. - """ - if not cxtm_file_path.exists(): - raise FileNotFoundError(f"cxtm.yaml not found: {cxtm_file_path.absolute()}") - - yaml_content = load_yaml_file(cxtm_file_path) - return CXTMConfiguration.model_validate(yaml_content) - - -def find_test_case_by_title( - cxtm_config: CXTMConfiguration, - title: str, - fuzzy_match: bool = True, -) -> tuple[CXTMTestCaseGroup | None, CXTMTestCase | None]: - """Find a test case in cxtm.yaml by its title. - - Searches through all test case groups to find a test case matching - the given title. Supports both exact and fuzzy matching. - - Args: - cxtm_config: Parsed cxtm.yaml configuration. - title: Title to search for (e.g., "[NX-OS] Verify Module Serial Number"). - fuzzy_match: If True, performs case-insensitive partial matching. - - Returns: - Tuple of (test_case_group, test_case) if found, (None, None) otherwise. - """ - # Normalize title for comparison - normalized_title = title.strip().lower() if fuzzy_match else title.strip() - - for group in cxtm_config.test_case_groups: - # Check group name - group_name = group.name.strip().lower() if fuzzy_match else group.name.strip() - - if fuzzy_match: - # Check if title matches group name or is contained within it - if normalized_title in group_name or group_name in normalized_title: - if group.test_cases: - return group, group.test_cases[0] - else: - if group_name == normalized_title: - if group.test_cases: - return group, group.test_cases[0] - - # Also check individual test case titles within the group - for test_case in group.test_cases: - tc_title = test_case.title.strip().lower() if fuzzy_match else test_case.title.strip() - if fuzzy_match: - if normalized_title in tc_title or tc_title in normalized_title: - return group, test_case - else: - if tc_title == normalized_title: - return group, test_case - - return None, None - - -def find_test_case_by_robot_file( - cxtm_config: CXTMConfiguration, - robot_file_path: str, -) -> tuple[CXTMTestCaseGroup | None, CXTMTestCase | None]: - """Find a test case in cxtm.yaml by its robot file path. - - Args: - cxtm_config: Parsed cxtm.yaml configuration. - robot_file_path: Path to the robot file (can be partial path). - - Returns: - Tuple of (test_case_group, test_case) if found, (None, None) otherwise. - """ - # Normalize path for comparison - normalized_path = robot_file_path.strip().lower() - - for group in cxtm_config.test_case_groups: - for test_case in group.test_cases: - if test_case.robot_file: - tc_robot_file = test_case.robot_file.strip().lower() - # Check for exact match or if one contains the other - if normalized_path in tc_robot_file or tc_robot_file in normalized_path: - return group, test_case - - return None, None - - -def extract_test_name_from_title(title: str) -> str: - """Extract the test name from a title in format '[OS] Test Name'. - - Args: - title: Full title string (e.g., "[NX-OS] Verify Module Serial Number"). - - Returns: - The test name portion (e.g., "Verify Module Serial Number"). - """ - pattern = re.compile(r"^\[(?P[^\]]+)\]\s+(?P.+)$") - match = pattern.match(title.strip()) - if match: - return match.group("test_name") - return title.strip() - - -def format_test_case_yaml_block( - group: CXTMTestCaseGroup, - test_case: CXTMTestCase, -) -> str: - """Format a test case definition as a YAML block for embedding in issues. - - Creates a structured YAML representation of the test case that can be - parsed by the webhook receiver to extract test case metadata. - - Args: - group: The test case group containing the test case. - test_case: The test case to format. - - Returns: - Formatted YAML string ready for embedding in an issue body. - """ - lines = [ - "test_case_group:", - f' name: "{group.name}"', - "test_case:", - f' identifier: "{test_case.identifier}"', - f' title: "{test_case.title}"', - ] - - if test_case.robot_file: - lines.append(f' robot_file: "{test_case.robot_file}"') - - if test_case.git_url: - lines.append(f' git_url: "{test_case.git_url}"') - - if test_case.git_commit_sha: - lines.append(f' git_commit_sha: "{test_case.git_commit_sha}"') - - return "\n".join(lines) From bdcf9869c10452de1db7235e4766cabad9f1f017 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Fri, 13 Feb 2026 15:06:14 +0000 Subject: [PATCH 7/8] fix: extract test_plan metadata for tracking issue YAML block The test_requirement dict passed to tracking_issue.j2 was missing the test_plan metadata fields (test_case_group_name, test_case_identifier, test_case_title). The template expected these fields but they were never populated from test_case["metadata"]["test_plan"]. Added extraction of test_plan metadata into the test_requirement dict so the metadata section is correctly rendered in GitHub issue bodies. Co-Authored-By: Claude Opus 4.6 --- .../synchronize/test_requirements.py | 7 ++++ .../test_synchronize_test_requirements.py | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/github_ops_manager/synchronize/test_requirements.py b/github_ops_manager/synchronize/test_requirements.py index 155e401..f2fbe9d 100644 --- a/github_ops_manager/synchronize/test_requirements.py +++ b/github_ops_manager/synchronize/test_requirements.py @@ -185,6 +185,13 @@ async def create_tracking_issue_for_catalog_test_case( "parameters_to_parsed_data_mapping": test_case.get("jobfile_parameters_mapping", ""), } + # Extract test_plan metadata if present for inclusion in tracking issue + test_plan = test_case.get("metadata", {}).get("test_plan", {}) + if test_plan: + test_requirement["test_case_group_name"] = test_plan.get("test_case_group_name", "") + test_requirement["test_case_identifier"] = test_plan.get("test_case_identifier", "") + test_requirement["test_case_title"] = test_plan.get("test_case_title", "") + # Load and render the tracking issue template template = load_tracking_issue_template() issue_body = template.render( diff --git a/tests/unit/test_synchronize_test_requirements.py b/tests/unit/test_synchronize_test_requirements.py index 16cb908..7cf1486 100644 --- a/tests/unit/test_synchronize_test_requirements.py +++ b/tests/unit/test_synchronize_test_requirements.py @@ -745,6 +745,48 @@ async def test_renders_suggested_branch_name(self) -> None: body = mock_adapter.create_issue.call_args[1]["body"] assert "learn/nxos/add-verify-nxos-interfaces" in body + @pytest.mark.asyncio + async def test_includes_test_plan_metadata_in_body(self) -> None: + """Should include test_plan metadata in tracking issue body when present.""" + mock_adapter = AsyncMock() + mock_issue = MagicMock() + mock_issue.number = 1 + mock_issue.html_url = "https://url" + mock_adapter.create_issue.return_value = mock_issue + + test_case: dict[str, Any] = { + "title": "[NX-OS] Verify Interface Status", + "purpose": "Check all interfaces are up", + "commands": [{"command": "show interface status"}], + "metadata": { + "catalog": {"destined": True}, + "catalog_tracking": { + "pr_number": 101, + "pr_url": "https://github.com/catalog/repo/pull/101", + "pr_branch": "feat/nxos/add-verify-nxos-interface-status", + }, + "test_plan": { + "test_case_group_name": "Verify Interface Status on all NX-OS devices", + "test_case_identifier": "1.0.42.", + "test_case_title": "Verify Interface Status on all NX-OS devices", + }, + }, + } + + await create_tracking_issue_for_catalog_test_case( + test_case, + mock_adapter, + "https://github.com/catalog/repo", + ) + + body = mock_adapter.create_issue.call_args[1]["body"] + # Verify test_plan metadata is included in the YAML block + assert "test_case_group_name" in body + assert "Verify Interface Status on all NX-OS devices" in body + assert "test_case_identifier" in body + assert "1.0.42." in body + assert "test_case_title" in body + class TestProcessTestRequirements: """Tests for process_test_requirements function.""" From 7eb0b47a38aab4dc7977140bef4cf09146b569a4 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Fri, 13 Feb 2026 15:20:21 +0000 Subject: [PATCH 8/8] fix: preserve YAML indentation in tracking issue template The `-%}` whitespace control on inner `{% if %}` blocks was stripping the 4-space indentation from test_plan metadata fields, causing them to render at column 0 instead of nested under metadata.test_plan. Removed `-` from inner conditionals to preserve the indentation. Co-Authored-By: Claude Opus 4.6 --- github_ops_manager/templates/tracking_issue.j2 | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index 77e5891..aa94e0d 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -23,16 +23,10 @@ parameters_to_parsed_data_mapping: | {% if test_requirement.test_case_group_name or test_requirement.test_case_identifier or test_requirement.test_case_title -%} metadata: test_plan: -{% if test_requirement.test_case_group_name -%} - test_case_group_name: "{{ test_requirement.test_case_group_name }}" -{% endif -%} -{% if test_requirement.test_case_identifier -%} - test_case_identifier: "{{ test_requirement.test_case_identifier }}" -{% endif -%} -{% if test_requirement.test_case_title -%} - test_case_title: "{{ test_requirement.test_case_title }}" -{% endif -%} -{% endif -%} +{% if test_requirement.test_case_group_name %} test_case_group_name: "{{ test_requirement.test_case_group_name }}" +{% endif %}{% if test_requirement.test_case_identifier %} test_case_identifier: "{{ test_requirement.test_case_identifier }}" +{% endif %}{% if test_requirement.test_case_title %} test_case_title: "{{ test_requirement.test_case_title }}" +{% endif %}{% endif -%} ``` ### Tasks