From b841a5e50e59ca6a26ff33d8e9398c9ae6498f7d Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Tue, 28 Oct 2025 14:55:24 -0400 Subject: [PATCH 01/22] feat: implement catalog workflow automation (issues #22, #23, #24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automated catalog contribution workflow that creates PRs against catalog repository with proper directory structure and writes metadata back to test case files. **Issue #22: Add catalog workflow CLI flags** - Added --catalog-workflow flag to enable catalog workflow mode - Added --catalog-repo option (default: US-PS-SVS/catalog) - Added --test-cases-dir option (default: workspace/test_cases/) - Threaded flags through driver.py to override target repo when catalog mode enabled - Built catalog repository URL from github_api_url for metadata writeback **Issue #23: Implement PR metadata writeback** - Created github_ops_manager/processing/test_cases_processor.py module - Implemented find_test_cases_files() to locate test_cases.yaml files - Implemented load_test_cases_yaml() and save_test_cases_yaml() with format preservation - Implemented find_test_case_by_filename() to match test cases by generated_script_path - Implemented update_test_case_with_pr_metadata() to add PR fields - Added write_pr_metadata_to_test_cases() async function in pull_requests.py - Writes catalog_pr_git_url, catalog_pr_number, catalog_pr_url, catalog_pr_branch back to YAML **Issue #24: Handle robot file copy to catalog structure** - Added OS_TO_CATALOG_DIR_MAP with 15+ OS mappings (ios-xe, nxos, iosxr, etc.) - Implemented normalize_os_to_catalog_dir() for OS name translation - Implemented extract_os_from_robot_filename() to parse filenames - Updated get_desired_pull_request_file_content() to transform paths for catalog - Robot files now placed in catalog// directory structure - Updated commit_files_to_branch() to accept and pass catalog_workflow flag - Updated sync_github_pull_request() to accept catalog parameters and call writeback - Updated sync_github_pull_requests() to thread catalog parameters **Files Changed:** - github_ops_manager/processing/test_cases_processor.py (NEW) - github_ops_manager/configuration/cli.py (CLI flags) - github_ops_manager/synchronize/driver.py (parameter threading, repo override) - github_ops_manager/synchronize/pull_requests.py (path transformation, writeback) **Benefits:** - Eliminates manual catalog contribution friction - Automatic PR creation with proper catalog directory structure - Full metadata trail from generation → PR → catalog integration - Test automation available via PR branch while under review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/configuration/cli.py | 14 ++ .../processing/test_cases_processor.py | 220 ++++++++++++++++++ github_ops_manager/synchronize/driver.py | 29 ++- .../synchronize/pull_requests.py | 172 +++++++++++++- 4 files changed, 424 insertions(+), 11 deletions(-) create mode 100644 github_ops_manager/processing/test_cases_processor.py diff --git a/github_ops_manager/configuration/cli.py b/github_ops_manager/configuration/cli.py index ad2b213..b42f8c2 100644 --- a/github_ops_manager/configuration/cli.py +++ b/github_ops_manager/configuration/cli.py @@ -204,6 +204,13 @@ def process_issues_cli( create_prs: Annotated[bool, Option(envvar="CREATE_PRS", help="Create PRs for issues.")] = False, debug: Annotated[bool, Option(envvar="DEBUG", help="Enable debug mode.")] = False, testing_as_code_workflow: Annotated[bool, Option(envvar="TESTING_AS_CODE_WORKFLOW", help="Enable Testing as Code workflow.")] = False, + catalog_workflow: Annotated[bool, Option(envvar="CATALOG_WORKFLOW", help="Enable catalog workflow for automated catalog contributions.")] = False, + catalog_repo: Annotated[ + str, Option(envvar="CATALOG_REPO", help="Catalog repository name (owner/repo) for catalog workflow.") + ] = "US-PS-SVS/catalog", + test_cases_dir: Annotated[Path, Option(envvar="TEST_CASES_DIR", help="Directory containing test_cases.yaml files for catalog workflow.")] = Path( + "workspace/test_cases/" + ), ) -> None: """Processes issues in a GitHub repository.""" repo: str = ctx.obj["repo"] @@ -217,6 +224,10 @@ def process_issues_cli( if testing_as_code_workflow is True: typer.echo("Testing as Code workflow is enabled - any Pull Requests created will have an augmented body") + if catalog_workflow is True: + typer.echo(f"Catalog workflow is enabled - PRs will target catalog repository: {catalog_repo}") + typer.echo(f"Test cases directory for metadata writeback: {test_cases_dir}") + # Run the workflow result = asyncio.run( run_process_issues_workflow( @@ -229,6 +240,9 @@ def process_issues_cli( github_api_url=github_api_url, yaml_path=yaml_path, testing_as_code_workflow=testing_as_code_workflow, + catalog_workflow=catalog_workflow, + catalog_repo=catalog_repo, + test_cases_dir=test_cases_dir, ) ) if result.errors: diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py new file mode 100644 index 0000000..aac3f8a --- /dev/null +++ b/github_ops_manager/processing/test_cases_processor.py @@ -0,0 +1,220 @@ +"""Handles processing of test case YAML files for catalog workflow. + +This module provides utilities for finding, loading, updating, and saving +test case definition files, particularly for writing PR metadata back after +catalog PR creation. +""" + +from pathlib import Path +from typing import Any + +import structlog +from githubkit.versions.latest.models import PullRequest +from ruamel.yaml import YAML + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# Initialize YAML handler with format preservation +yaml = YAML() +yaml.preserve_quotes = True +yaml.default_flow_style = False + + +# Mapping from tac-quicksilver normalized OS to catalog directory names +OS_TO_CATALOG_DIR_MAP = { + "iosxe": "IOS-XE", + "ios-xe": "IOS-XE", + "ios_xe": "IOS-XE", + "nxos": "NX-OS", + "nx-os": "NX-OS", + "nx_os": "NX-OS", + "iosxr": "IOS-XR", + "ios-xr": "IOS-XR", + "ios_xr": "IOS-XR", + "ios": "IOS", + "ise": "ISE", + "aci": "ACI", + "sdwan": "SD-WAN", + "sd-wan": "SD-WAN", + "dnac": "DNAC", + "catalyst_center": "DNAC", + "spirent": "Spirent", +} + + +def normalize_os_to_catalog_dir(os_name: str) -> str: + """Convert normalized OS name to catalog directory name. + + Args: + os_name: The OS name in normalized form (e.g., "iosxe", "nxos") + + Returns: + Catalog directory name (e.g., "IOS-XE", "NX-OS") + + Example: + >>> normalize_os_to_catalog_dir("ios_xe") + 'IOS-XE' + >>> normalize_os_to_catalog_dir("nxos") + 'NX-OS' + """ + normalized = OS_TO_CATALOG_DIR_MAP.get(os_name.lower(), os_name.upper()) + logger.debug("Normalized OS name to catalog directory", os_name=os_name, catalog_dir=normalized) + return normalized + + +def extract_os_from_robot_filename(filename: str) -> str | None: + """Extract OS from robot filename pattern like verify_ios_xe_*.robot. + + Args: + filename: Robot filename (e.g., "verify_ios_xe_interfaces.robot") + + Returns: + Extracted OS name or None if pattern doesn't match + + Example: + >>> extract_os_from_robot_filename("verify_ios_xe_interfaces.robot") + 'ios_xe' + >>> extract_os_from_robot_filename("verify_nx_os_vlans.robot") + 'nx_os' + """ + # Remove .robot extension + base_name = filename.replace(".robot", "") + + # Pattern: __.robot + # OS is typically second part (verify_ios_xe_...) + parts = base_name.split("_") + + if len(parts) >= 3: + # Try two-part OS first (ios_xe, nx_os, ios_xr) + potential_os = f"{parts[1]}_{parts[2]}" + if potential_os in OS_TO_CATALOG_DIR_MAP: + logger.debug("Extracted two-part OS from filename", filename=filename, os=potential_os) + return potential_os + + if len(parts) >= 2: + # Try single-part OS (iosxe, nxos, iosxr, ise, aci, etc.) + potential_os = parts[1] + if potential_os in OS_TO_CATALOG_DIR_MAP: + logger.debug("Extracted single-part OS from filename", filename=filename, os=potential_os) + return potential_os + + logger.warning("Could not extract OS from robot filename", filename=filename) + return None + + +def find_test_cases_files(test_cases_dir: Path) -> list[Path]: + """Find all test_cases.yaml files in directory. + + Args: + test_cases_dir: Directory to search for test case files + + Returns: + List of paths to test_cases.yaml files + """ + if not test_cases_dir.exists(): + logger.error("Test cases directory does not exist", test_cases_dir=str(test_cases_dir)) + return [] + + # Look for .yaml and .yml files + yaml_files = list(test_cases_dir.glob("**/*.yaml")) + list(test_cases_dir.glob("**/*.yml")) + + # Filter for files that likely contain test cases + test_case_files = [] + for yaml_file in yaml_files: + if "test_case" in yaml_file.name.lower(): + test_case_files.append(yaml_file) + + logger.info("Found test case files", count=len(test_case_files), test_cases_dir=str(test_cases_dir)) + return test_case_files + + +def load_test_cases_yaml(filepath: Path) -> dict[str, Any] | None: + """Load test cases YAML preserving formatting. + + Args: + filepath: Path to test cases YAML file + + Returns: + Dictionary containing test cases data, or None on error + """ + try: + with open(filepath, encoding="utf-8") as f: + data = yaml.load(f) + + if not isinstance(data, dict): + logger.error("Test cases file is not a dictionary", filepath=str(filepath)) + return None + + logger.debug("Loaded test cases YAML", filepath=str(filepath), has_test_cases="test_cases" in data) + return data + + except Exception as e: + logger.error("Failed to load test cases YAML", filepath=str(filepath), error=str(e)) + return None + + +def save_test_cases_yaml(filepath: Path, data: dict[str, Any]) -> bool: + """Save test cases YAML preserving formatting. + + Args: + filepath: Path to test cases YAML file + data: Dictionary containing test cases data + + Returns: + True if save succeeded, False otherwise + """ + try: + with open(filepath, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + logger.info("Saved test cases YAML", filepath=str(filepath)) + return True + + except Exception as e: + logger.error("Failed to save test cases YAML", filepath=str(filepath), error=str(e)) + return False + + +def find_test_case_by_filename(test_cases: list[dict[str, Any]], generated_script_path: str) -> tuple[int, dict[str, Any]] | None: + """Find test case by matching generated_script_path field. + + Args: + test_cases: List of test case dictionaries + generated_script_path: Generated script path to match + + Returns: + Tuple of (index, test_case) or None if not found + """ + for idx, test_case in enumerate(test_cases): + if test_case.get("generated_script_path") == generated_script_path: + logger.debug("Found matching test case", index=idx, generated_script_path=generated_script_path) + return (idx, test_case) + + logger.debug("No matching test case found", generated_script_path=generated_script_path) + return None + + +def update_test_case_with_pr_metadata(test_case: dict[str, Any], pr: PullRequest, catalog_repo_url: str) -> dict[str, Any]: + """Add PR metadata fields to test case. + + Args: + test_case: Test case dictionary to update + pr: GitHub PullRequest object + catalog_repo_url: Full URL to catalog repository + + Returns: + Updated test case dictionary + """ + test_case["catalog_pr_git_url"] = catalog_repo_url + test_case["catalog_pr_number"] = pr.number + test_case["catalog_pr_url"] = pr.html_url + test_case["catalog_pr_branch"] = pr.head.ref + + logger.info( + "Updated test case with PR metadata", + catalog_pr_number=pr.number, + catalog_pr_url=pr.html_url, + catalog_pr_branch=pr.head.ref, + ) + + return test_case diff --git a/github_ops_manager/synchronize/driver.py b/github_ops_manager/synchronize/driver.py index 6d1ef58..2350223 100644 --- a/github_ops_manager/synchronize/driver.py +++ b/github_ops_manager/synchronize/driver.py @@ -27,6 +27,9 @@ async def run_process_issues_workflow( yaml_path: Path, raise_on_yaml_error: bool = False, testing_as_code_workflow: bool = False, + catalog_workflow: bool = False, + catalog_repo: str = "US-PS-SVS/catalog", + test_cases_dir: Path = Path("workspace/test_cases/"), ) -> ProcessIssuesResult: """Run the process-issues workflow: load issues from YAML and return them/errors.""" processor = YAMLProcessor(raise_on_error=raise_on_yaml_error) @@ -39,9 +42,15 @@ async def run_process_issues_workflow( if issues_model.issue_template: issues_model = await render_issue_bodies(issues_model) + # Override repo if catalog workflow is enabled + target_repo = repo + if catalog_workflow: + target_repo = catalog_repo + logger.info("Catalog workflow enabled, targeting catalog repository", catalog_repo=catalog_repo, original_repo=repo) + # Set up GitHub adapter. github_adapter = await GitHubKitAdapter.create( - repo=repo, + repo=target_repo, github_auth_type=github_auth_type, github_pat_token=github_pat_token, github_app_id=github_app_id, @@ -116,6 +125,21 @@ async def run_process_issues_workflow( start_time = time.time() logger.info("Processing pull requests", start_time=start_time) + + # Build catalog repo URL if catalog workflow is enabled + catalog_repo_url = None + if catalog_workflow: + # Extract base URL from github_api_url + # e.g., "https://api.github.com" -> "https://github.com" + # or "https://wwwin-github.cisco.com/api/v3" -> "https://wwwin-github.cisco.com" + if "api.github.com" in github_api_url: + base_url = "https://github.com" + else: + # For GitHub Enterprise, remove /api/v3 suffix + base_url = github_api_url.replace("/api/v3", "").replace("/api", "") + catalog_repo_url = f"{base_url}/{catalog_repo}" + logger.info("Built catalog repository URL", catalog_repo_url=catalog_repo_url) + await sync_github_pull_requests( issues_model.issues, refreshed_issues, @@ -124,6 +148,9 @@ async def run_process_issues_workflow( default_branch, yaml_dir, testing_as_code_workflow=testing_as_code_workflow, + catalog_workflow=catalog_workflow, + catalog_repo_url=catalog_repo_url, + test_cases_dir=test_cases_dir, ) end_time = time.time() total_time = end_time - start_time diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index 872a9d7..4cab1f1 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -8,6 +8,14 @@ from structlog.contextvars import bound_contextvars from github_ops_manager.github.adapter import GitHubKitAdapter +from github_ops_manager.processing.test_cases_processor import ( + extract_os_from_robot_filename, + find_test_cases_files, + load_test_cases_yaml, + normalize_os_to_catalog_dir, + save_test_cases_yaml, + update_test_case_with_pr_metadata, +) from github_ops_manager.schemas.default_issue import IssueModel, PullRequestModel from github_ops_manager.synchronize.models import SyncDecision from github_ops_manager.synchronize.utils import compare_github_field, compare_label_sets @@ -74,8 +82,19 @@ async def get_pull_request_associated_with_issue(issue: Issue, existing_pull_req return None -async def get_desired_pull_request_file_content(base_directory: Path, desired_issue: IssueModel) -> list[tuple[str, str]]: - """Get the content of the desired pull request files.""" +async def get_desired_pull_request_file_content( + base_directory: Path, desired_issue: IssueModel, catalog_workflow: bool = False +) -> list[tuple[str, str]]: + """Get the content of the desired pull request files. + + Args: + base_directory: Base directory where files are located + desired_issue: Issue model containing pull request information + catalog_workflow: If True, transform .robot file paths to catalog structure + + Returns: + List of tuples (file_path_in_pr, file_content) + """ if desired_issue.pull_request is None: raise ValueError("Desired issue has no pull request associated with it") files: list[tuple[str, str]] = [] @@ -83,7 +102,28 @@ async def get_desired_pull_request_file_content(base_directory: Path, desired_is file_path = base_directory / file logger.info("Checking if file exists", file=file, file_path=str(file_path), base_directory=str(base_directory)) if file_path.exists(): - files.append((file, file_path.read_text(encoding="utf-8"))) + file_content = file_path.read_text(encoding="utf-8") + + # Transform path if catalog workflow and file is a robot file + if catalog_workflow and file.endswith(".robot"): + filename = Path(file).name + os_name = extract_os_from_robot_filename(filename) + if os_name: + catalog_dir = normalize_os_to_catalog_dir(os_name) + catalog_path = f"catalog/{catalog_dir}/{filename}" + logger.info( + "Transformed robot file path for catalog", + original_path=file, + catalog_path=catalog_path, + os_name=os_name, + catalog_dir=catalog_dir, + ) + files.append((catalog_path, file_content)) + else: + logger.warning("Could not extract OS from robot filename, using original path", filename=filename) + files.append((file, file_content)) + else: + files.append((file, file_content)) else: logger.warning("Pull Request file not found", file=file, issue_title=desired_issue.title) return files @@ -155,9 +195,23 @@ async def decide_github_pull_request_sync_action(desired_issue: IssueModel, exis async def commit_files_to_branch( - desired_issue: IssueModel, existing_issue: Issue, desired_branch_name: str, base_directory: Path, github_adapter: GitHubKitAdapter + desired_issue: IssueModel, + existing_issue: Issue, + desired_branch_name: str, + base_directory: Path, + github_adapter: GitHubKitAdapter, + catalog_workflow: bool = False, ) -> None: - """Commit files to a branch.""" + """Commit files to a branch. + + Args: + desired_issue: Issue model containing pull request information + existing_issue: GitHub Issue object + desired_branch_name: Name of the branch to commit to + base_directory: Base directory where files are located + github_adapter: GitHub adapter for API calls + catalog_workflow: If True, transform robot file paths to catalog structure + """ if desired_issue.pull_request is None: raise ValueError("Desired issue has no pull request associated with it") @@ -166,7 +220,7 @@ async def commit_files_to_branch( logger.info("Preparing files to commit for pull request", issue_title=desired_issue.title, branch=desired_branch_name) for file_path in desired_issue.pull_request.files: try: - files_to_commit = await get_desired_pull_request_file_content(base_directory, desired_issue) + files_to_commit = await get_desired_pull_request_file_content(base_directory, desired_issue, catalog_workflow) except FileNotFoundError as exc: logger.error("File for PR not found or unreadable", file=file_path, error=str(exc)) missing_files.append(file_path) @@ -182,6 +236,77 @@ async def commit_files_to_branch( await github_adapter.commit_files_to_branch(desired_branch_name, files_to_commit, commit_message) +async def write_pr_metadata_to_test_cases( + pr: PullRequest, + catalog_repo_url: str, + test_cases_dir: Path, +) -> None: + """Write PR metadata back to test_cases.yaml files after catalog PR creation. + + Args: + pr: GitHub PullRequest object with created PR information + catalog_repo_url: Full URL to catalog repository + test_cases_dir: Directory containing test_cases.yaml files + """ + logger.info("Writing PR metadata to test cases files", pr_number=pr.number, test_cases_dir=str(test_cases_dir)) + + # Get robot filename from PR files + pr_files = [f.filename for f in pr.changed_files] if hasattr(pr, "changed_files") else [] + robot_files = [f for f in pr_files if f.endswith(".robot")] + + if not robot_files: + logger.warning("No robot files found in PR, cannot write back metadata", pr_number=pr.number) + return + + # For catalog PRs, the filename will be in format: catalog//.robot + # We need to extract just the filename + robot_filename = Path(robot_files[0]).name + + logger.info("Processing robot file for metadata writeback", robot_filename=robot_filename, pr_number=pr.number) + + # Find test_cases.yaml files + test_case_files = find_test_cases_files(test_cases_dir) + + if not test_case_files: + logger.warning("No test case files found in directory", test_cases_dir=str(test_cases_dir)) + return + + # Search through test case files for matching test case + for test_case_file in test_case_files: + data = load_test_cases_yaml(test_case_file) + if not data or "test_cases" not in data: + continue + + test_cases = data["test_cases"] + if not isinstance(test_cases, list): + logger.warning("test_cases field is not a list", filepath=str(test_case_file)) + continue + + # Look for test case with matching generated_script_path + # The generated_script_path might be just the filename or include a directory + for test_case in test_cases: + generated_script_path = test_case.get("generated_script_path") + if generated_script_path and Path(generated_script_path).name == robot_filename: + logger.info( + "Found matching test case, updating with PR metadata", + test_case_file=str(test_case_file), + generated_script_path=generated_script_path, + ) + + # Update test case with PR metadata + update_test_case_with_pr_metadata(test_case, pr, catalog_repo_url) + + # Save updated YAML + if save_test_cases_yaml(test_case_file, data): + logger.info("Successfully wrote PR metadata back to test case file", test_case_file=str(test_case_file)) + return + else: + logger.error("Failed to save test case file", test_case_file=str(test_case_file)) + return + + logger.warning("No matching test case found for robot file", robot_filename=robot_filename) + + async def sync_github_pull_request( desired_issue: IssueModel, existing_issue: Issue, @@ -190,6 +315,9 @@ async def sync_github_pull_request( base_directory: Path, existing_pull_request: PullRequest | None = None, testing_as_code_workflow: bool = False, + catalog_workflow: bool = False, + catalog_repo_url: str | None = None, + test_cases_dir: Path | None = None, ) -> None: """Synchronize a specific pull request for an issue.""" with bound_contextvars( @@ -235,7 +363,7 @@ async def sync_github_pull_request( logger.info("Branch already exists, skipping creation", branch=desired_branch_name) # Commit files to branch - await commit_files_to_branch(desired_issue, existing_issue, desired_branch_name, base_directory, github_adapter) + await commit_files_to_branch(desired_issue, existing_issue, desired_branch_name, base_directory, github_adapter, catalog_workflow) logger.info("Creating new PR for issue", branch=desired_branch_name, base_branch=default_branch) new_pr = await github_adapter.create_pull_request( @@ -247,6 +375,11 @@ async def sync_github_pull_request( logger.info("Created new PR", pr_number=new_pr.number, branch=desired_branch_name) await github_adapter.set_labels_on_issue(new_pr.number, pr_labels) logger.info("Set labels on new PR", pr_number=new_pr.number, labels=pr_labels) + + # Write PR metadata back to test_cases.yaml if catalog workflow + if catalog_workflow and catalog_repo_url and test_cases_dir: + logger.info("Catalog workflow enabled, writing PR metadata back to test cases") + await write_pr_metadata_to_test_cases(new_pr, catalog_repo_url, test_cases_dir) elif pr_sync_decision == SyncDecision.UPDATE: if existing_pull_request is None: raise ValueError("Existing pull request not found") @@ -257,12 +390,12 @@ async def sync_github_pull_request( body=pr.body, ) await github_adapter.set_labels_on_issue(existing_pull_request.number, pr_labels) - desired_file_data = await get_desired_pull_request_file_content(base_directory, desired_issue) + desired_file_data = await get_desired_pull_request_file_content(base_directory, desired_issue, catalog_workflow) pr_file_sync_decision = await decide_github_pull_request_file_sync_action(desired_file_data, existing_pull_request, github_adapter) if pr_file_sync_decision == SyncDecision.CREATE: # The branch will already exist, so we don't need to create it. # However, we do need to commit the files to the branch. - await commit_files_to_branch(desired_issue, existing_issue, desired_branch_name, base_directory, github_adapter) + await commit_files_to_branch(desired_issue, existing_issue, desired_branch_name, base_directory, github_adapter, catalog_workflow) async def sync_github_pull_requests( @@ -273,8 +406,24 @@ async def sync_github_pull_requests( default_branch: str, base_directory: Path, testing_as_code_workflow: bool = False, + catalog_workflow: bool = False, + catalog_repo_url: str | None = None, + test_cases_dir: Path | None = None, ) -> None: - """Process pull requests for issues that specify a pull_request field.""" + """Process pull requests for issues that specify a pull_request field. + + Args: + desired_issues: List of desired issues from YAML + existing_issues: List of existing issues from GitHub + existing_pull_requests: List of existing pull requests from GitHub + github_adapter: GitHub adapter for API calls + default_branch: Default branch name + base_directory: Base directory where files are located + testing_as_code_workflow: If True, augment PR bodies for Testing as Code + catalog_workflow: If True, enable catalog workflow features + catalog_repo_url: Full URL to catalog repository (required if catalog_workflow=True) + test_cases_dir: Directory containing test_cases.yaml files (required if catalog_workflow=True) + """ desired_issues_with_prs = [issue for issue in desired_issues if issue.pull_request is not None] for desired_issue in desired_issues_with_prs: existing_issue = next((issue for issue in existing_issues if issue.title == desired_issue.title), None) @@ -300,4 +449,7 @@ async def sync_github_pull_requests( base_directory, existing_pull_request=existing_pr, testing_as_code_workflow=testing_as_code_workflow, + catalog_workflow=catalog_workflow, + catalog_repo_url=catalog_repo_url, + test_cases_dir=test_cases_dir, ) From 10d2be284e9b1994c6546ec3930aaff2b2586ce5 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Tue, 28 Oct 2025 17:51:49 -0400 Subject: [PATCH 02/22] refactor: use catalog_destined per-issue instead of global CLI flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors catalog workflow to be data-driven by reading catalog_destined attribute from individual test cases rather than using a global --catalog-workflow flag. This allows mixing catalog and non-catalog PRs in the same run. **Key Changes:** **CLI (configuration/cli.py:200-254)** - Removed --catalog-workflow flag - Kept --catalog-repo and --test-cases-dir as configuration options - Updated help text to indicate use with catalog_destined=true test cases - Added docstring explaining automatic detection behavior **Driver (synchronize/driver.py:19-159)** - Removed catalog_workflow parameter from run_process_issues_workflow() - Removed conditional repo override logic (now handled per-PR) - Always passes catalog config and auth parameters to sync_github_pull_requests() - Builds catalog_repo_url unconditionally for potential use **Pull Requests (synchronize/pull_requests.py:401-534)** - sync_github_pull_requests() now accepts auth parameters - Detects catalog-destined issues via getattr(issue, 'catalog_destined', False) - Creates catalog adapter only if catalog-destined issues exist - Fetches catalog repository state (issues, PRs, default branch) - Routes each PR to appropriate adapter based on catalog_destined attribute - Catalog workflow features (path transformation, metadata writeback) applied per-issue **Benefits:** - Granular control: Mix catalog and non-catalog in same test_cases.yaml - Data-driven design: Test cases declare their own intent - Single workflow run handles both types - Cleaner separation: tac-quicksilver sets flag, github-ops-manager respects it - More flexible and maintainable **Breaking Change:** - --catalog-workflow CLI flag removed (replaced by per-issue catalog_destined field) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/configuration/cli.py | 29 +++-- github_ops_manager/synchronize/driver.py | 49 ++++---- .../synchronize/pull_requests.py | 105 +++++++++++++++--- 3 files changed, 135 insertions(+), 48 deletions(-) diff --git a/github_ops_manager/configuration/cli.py b/github_ops_manager/configuration/cli.py index b42f8c2..bf17e67 100644 --- a/github_ops_manager/configuration/cli.py +++ b/github_ops_manager/configuration/cli.py @@ -204,15 +204,27 @@ def process_issues_cli( create_prs: Annotated[bool, Option(envvar="CREATE_PRS", help="Create PRs for issues.")] = False, debug: Annotated[bool, Option(envvar="DEBUG", help="Enable debug mode.")] = False, testing_as_code_workflow: Annotated[bool, Option(envvar="TESTING_AS_CODE_WORKFLOW", help="Enable Testing as Code workflow.")] = False, - catalog_workflow: Annotated[bool, Option(envvar="CATALOG_WORKFLOW", help="Enable catalog workflow for automated catalog contributions.")] = False, catalog_repo: Annotated[ - str, Option(envvar="CATALOG_REPO", help="Catalog repository name (owner/repo) for catalog workflow.") + str, + Option( + envvar="CATALOG_REPO", + help="Catalog repository name (owner/repo) for catalog-destined test cases. Used when test cases have catalog_destined=true.", + ), ] = "US-PS-SVS/catalog", - test_cases_dir: Annotated[Path, Option(envvar="TEST_CASES_DIR", help="Directory containing test_cases.yaml files for catalog workflow.")] = Path( - "workspace/test_cases/" - ), + test_cases_dir: Annotated[ + Path, + Option( + envvar="TEST_CASES_DIR", + help="Directory containing test_cases.yaml files for catalog PR metadata writeback. Used when test cases have catalog_destined=true.", + ), + ] = Path("workspace/test_cases/"), ) -> None: - """Processes issues in a GitHub repository.""" + """Processes issues in a GitHub repository. + + Automatically detects catalog-destined test cases (catalog_destined=true) and creates + PRs against the catalog repository with proper directory structure and metadata writeback. + Non-catalog test cases are processed normally against the project repository. + """ repo: str = ctx.obj["repo"] github_api_url: str = ctx.obj["github_api_url"] github_pat_token: str = ctx.obj["github_pat_token"] @@ -224,10 +236,6 @@ def process_issues_cli( if testing_as_code_workflow is True: typer.echo("Testing as Code workflow is enabled - any Pull Requests created will have an augmented body") - if catalog_workflow is True: - typer.echo(f"Catalog workflow is enabled - PRs will target catalog repository: {catalog_repo}") - typer.echo(f"Test cases directory for metadata writeback: {test_cases_dir}") - # Run the workflow result = asyncio.run( run_process_issues_workflow( @@ -240,7 +248,6 @@ def process_issues_cli( github_api_url=github_api_url, yaml_path=yaml_path, testing_as_code_workflow=testing_as_code_workflow, - catalog_workflow=catalog_workflow, catalog_repo=catalog_repo, test_cases_dir=test_cases_dir, ) diff --git a/github_ops_manager/synchronize/driver.py b/github_ops_manager/synchronize/driver.py index 2350223..339d9ee 100644 --- a/github_ops_manager/synchronize/driver.py +++ b/github_ops_manager/synchronize/driver.py @@ -27,11 +27,14 @@ async def run_process_issues_workflow( yaml_path: Path, raise_on_yaml_error: bool = False, testing_as_code_workflow: bool = False, - catalog_workflow: bool = False, catalog_repo: str = "US-PS-SVS/catalog", test_cases_dir: Path = Path("workspace/test_cases/"), ) -> ProcessIssuesResult: - """Run the process-issues workflow: load issues from YAML and return them/errors.""" + """Run the process-issues workflow: load issues from YAML and return them/errors. + + Supports both project and catalog-destined test cases in the same run. + Issues with catalog_destined=true will have PRs created against the catalog repository. + """ processor = YAMLProcessor(raise_on_error=raise_on_yaml_error) try: issues_model = processor.load_issues_model([str(yaml_path)]) @@ -42,15 +45,9 @@ async def run_process_issues_workflow( if issues_model.issue_template: issues_model = await render_issue_bodies(issues_model) - # Override repo if catalog workflow is enabled - target_repo = repo - if catalog_workflow: - target_repo = catalog_repo - logger.info("Catalog workflow enabled, targeting catalog repository", catalog_repo=catalog_repo, original_repo=repo) - - # Set up GitHub adapter. + # Set up GitHub adapter for project repository. github_adapter = await GitHubKitAdapter.create( - repo=target_repo, + repo=repo, github_auth_type=github_auth_type, github_pat_token=github_pat_token, github_app_id=github_app_id, @@ -126,19 +123,15 @@ async def run_process_issues_workflow( start_time = time.time() logger.info("Processing pull requests", start_time=start_time) - # Build catalog repo URL if catalog workflow is enabled - catalog_repo_url = None - if catalog_workflow: - # Extract base URL from github_api_url - # e.g., "https://api.github.com" -> "https://github.com" - # or "https://wwwin-github.cisco.com/api/v3" -> "https://wwwin-github.cisco.com" - if "api.github.com" in github_api_url: - base_url = "https://github.com" - else: - # For GitHub Enterprise, remove /api/v3 suffix - base_url = github_api_url.replace("/api/v3", "").replace("/api", "") - catalog_repo_url = f"{base_url}/{catalog_repo}" - logger.info("Built catalog repository URL", catalog_repo_url=catalog_repo_url) + # Build catalog repo URL for metadata writeback + # e.g., "https://api.github.com" -> "https://github.com" + # or "https://wwwin-github.cisco.com/api/v3" -> "https://wwwin-github.cisco.com" + if "api.github.com" in github_api_url: + base_url = "https://github.com" + else: + # For GitHub Enterprise, remove /api/v3 suffix + base_url = github_api_url.replace("/api/v3", "").replace("/api", "") + catalog_repo_url = f"{base_url}/{catalog_repo}" await sync_github_pull_requests( issues_model.issues, @@ -148,9 +141,17 @@ async def run_process_issues_workflow( default_branch, yaml_dir, testing_as_code_workflow=testing_as_code_workflow, - catalog_workflow=catalog_workflow, + # Catalog configuration for catalog-destined issues + catalog_repo=catalog_repo, catalog_repo_url=catalog_repo_url, test_cases_dir=test_cases_dir, + # Auth parameters for creating catalog adapter + github_auth_type=github_auth_type, + github_pat_token=github_pat_token, + github_app_id=github_app_id, + github_app_private_key_path=github_app_private_key_path, + github_app_installation_id=github_app_installation_id, + github_api_url=github_api_url, ) end_time = time.time() total_time = end_time - start_time diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index 4cab1f1..8dc6808 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -406,27 +406,106 @@ async def sync_github_pull_requests( default_branch: str, base_directory: Path, testing_as_code_workflow: bool = False, - catalog_workflow: bool = False, + catalog_repo: str = "US-PS-SVS/catalog", catalog_repo_url: str | None = None, test_cases_dir: Path | None = None, + github_auth_type: str | None = None, + github_pat_token: str | None = None, + github_app_id: int | None = None, + github_app_private_key_path: Path | None = None, + github_app_installation_id: int | None = None, + github_api_url: str = "https://api.github.com", ) -> None: """Process pull requests for issues that specify a pull_request field. + Supports both project and catalog-destined PRs in the same run. + Issues with catalog_destined=true will have PRs created against the catalog repository. + Args: desired_issues: List of desired issues from YAML - existing_issues: List of existing issues from GitHub + existing_issues: List of existing issues from GitHub (project repo) existing_pull_requests: List of existing pull requests from GitHub - github_adapter: GitHub adapter for API calls + github_adapter: GitHub adapter for project repository default_branch: Default branch name base_directory: Base directory where files are located testing_as_code_workflow: If True, augment PR bodies for Testing as Code - catalog_workflow: If True, enable catalog workflow features - catalog_repo_url: Full URL to catalog repository (required if catalog_workflow=True) - test_cases_dir: Directory containing test_cases.yaml files (required if catalog_workflow=True) + catalog_repo: Catalog repository name (owner/repo) + catalog_repo_url: Full URL to catalog repository for metadata writeback + test_cases_dir: Directory containing test_cases.yaml files for metadata writeback + github_auth_type: GitHub authentication type for creating catalog adapter + github_pat_token: GitHub PAT token for creating catalog adapter + github_app_id: GitHub App ID for creating catalog adapter + github_app_private_key_path: GitHub App private key path for creating catalog adapter + github_app_installation_id: GitHub App installation ID for creating catalog adapter + github_api_url: GitHub API URL for creating catalog adapter """ + from github_ops_manager.configuration.models import GitHubAuthenticationType + desired_issues_with_prs = [issue for issue in desired_issues if issue.pull_request is not None] + + # Check if we have any catalog-destined issues + catalog_destined_issues = [issue for issue in desired_issues_with_prs if getattr(issue, "catalog_destined", False)] + + catalog_adapter = None + catalog_issues = None + catalog_prs = None + catalog_default_branch = None + + if catalog_destined_issues: + logger.info( + "Detected catalog-destined issues, creating catalog adapter", + catalog_count=len(catalog_destined_issues), + total_count=len(desired_issues_with_prs), + catalog_repo=catalog_repo, + ) + + # Create adapter for catalog repository + catalog_adapter = await GitHubKitAdapter.create( + repo=catalog_repo, + github_auth_type=GitHubAuthenticationType(github_auth_type) if github_auth_type else None, + github_pat_token=github_pat_token, + github_app_id=github_app_id, + github_app_private_key_path=github_app_private_key_path, + github_app_installation_id=github_app_installation_id, + github_api_url=github_api_url, + ) + + # Get catalog repository info + catalog_repo_info = await catalog_adapter.get_repository() + catalog_default_branch = catalog_repo_info.default_branch + + # Fetch existing issues and PRs from catalog repository + catalog_issues = await catalog_adapter.list_issues() + catalog_simple_prs = await catalog_adapter.list_pull_requests() + catalog_prs = [await catalog_adapter.get_pull_request(pr.number) for pr in catalog_simple_prs] + + logger.info( + "Fetched catalog repository state", + catalog_issues_count=len(catalog_issues), + catalog_prs_count=len(catalog_prs), + catalog_default_branch=catalog_default_branch, + ) + + # Process each issue for desired_issue in desired_issues_with_prs: - existing_issue = next((issue for issue in existing_issues if issue.title == desired_issue.title), None) + is_catalog_destined = getattr(desired_issue, "catalog_destined", False) + + if is_catalog_destined: + # Use catalog adapter and state + adapter = catalog_adapter + issues_list = catalog_issues + prs_list = catalog_prs + branch = catalog_default_branch + logger.info("Processing catalog-destined issue", issue_title=desired_issue.title) + else: + # Use project adapter and state + adapter = github_adapter + issues_list = existing_issues + prs_list = existing_pull_requests + branch = default_branch + logger.info("Processing project issue", issue_title=desired_issue.title) + + existing_issue = next((issue for issue in issues_list if issue.title == desired_issue.title), None) if existing_issue is not None: logger.info( "Existing issue found", @@ -439,17 +518,17 @@ async def sync_github_pull_requests( continue # Find existing PR associated with existing issue, if any. - existing_pr = await get_pull_request_associated_with_issue(existing_issue, existing_pull_requests) + existing_pr = await get_pull_request_associated_with_issue(existing_issue, prs_list) await sync_github_pull_request( desired_issue, existing_issue, - github_adapter, - default_branch, + adapter, + branch, base_directory, existing_pull_request=existing_pr, testing_as_code_workflow=testing_as_code_workflow, - catalog_workflow=catalog_workflow, - catalog_repo_url=catalog_repo_url, - test_cases_dir=test_cases_dir, + catalog_workflow=is_catalog_destined, + catalog_repo_url=catalog_repo_url if is_catalog_destined else None, + test_cases_dir=test_cases_dir if is_catalog_destined else None, ) From fa36308aae85dd5d5321cd760f9af16669891ecc Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Tue, 28 Oct 2025 17:54:34 -0400 Subject: [PATCH 03/22] fix: update default catalog repository to Testing-as-Code/tac-catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes default catalog repository from US-PS-SVS/catalog to Testing-as-Code/tac-catalog across all configuration points. Updated in: - github_ops_manager/configuration/cli.py (CLI default) - github_ops_manager/synchronize/driver.py (function parameter) - github_ops_manager/synchronize/pull_requests.py (function parameter) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/configuration/cli.py | 2 +- github_ops_manager/synchronize/driver.py | 2 +- github_ops_manager/synchronize/pull_requests.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/github_ops_manager/configuration/cli.py b/github_ops_manager/configuration/cli.py index bf17e67..41246d5 100644 --- a/github_ops_manager/configuration/cli.py +++ b/github_ops_manager/configuration/cli.py @@ -210,7 +210,7 @@ def process_issues_cli( envvar="CATALOG_REPO", help="Catalog repository name (owner/repo) for catalog-destined test cases. Used when test cases have catalog_destined=true.", ), - ] = "US-PS-SVS/catalog", + ] = "Testing-as-Code/tac-catalog", test_cases_dir: Annotated[ Path, Option( diff --git a/github_ops_manager/synchronize/driver.py b/github_ops_manager/synchronize/driver.py index 339d9ee..75f2d61 100644 --- a/github_ops_manager/synchronize/driver.py +++ b/github_ops_manager/synchronize/driver.py @@ -27,7 +27,7 @@ async def run_process_issues_workflow( yaml_path: Path, raise_on_yaml_error: bool = False, testing_as_code_workflow: bool = False, - catalog_repo: str = "US-PS-SVS/catalog", + catalog_repo: str = "Testing-as-Code/tac-catalog", test_cases_dir: Path = Path("workspace/test_cases/"), ) -> ProcessIssuesResult: """Run the process-issues workflow: load issues from YAML and return them/errors. diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index 8dc6808..6475f44 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -406,7 +406,7 @@ async def sync_github_pull_requests( default_branch: str, base_directory: Path, testing_as_code_workflow: bool = False, - catalog_repo: str = "US-PS-SVS/catalog", + catalog_repo: str = "Testing-as-Code/tac-catalog", catalog_repo_url: str | None = None, test_cases_dir: Path | None = None, github_auth_type: str | None = None, From 8ec7f144cabd0bdc4bd72e21e562a74d671f86b8 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Tue, 28 Oct 2025 18:00:31 -0400 Subject: [PATCH 04/22] refactor: extract OS from Test Tags instead of filename parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves reliability of OS detection by parsing the os: tag from the Test Tags section in robot files rather than inferring from filenames. **Why This Is Better:** - Test Tags are structured metadata intentionally placed by tac-quicksilver - More reliable than filename parsing (filenames can vary) - Uses regex pattern to find os: tag in robot file content - Falls back to filename parsing if Test Tags parsing fails **Implementation:** - Added extract_os_from_robot_content() with regex pattern `os:([a-zA-Z0-9_-]+)` - Updated extract_os_from_robot_filename() docstring to note it's a fallback - Modified get_desired_pull_request_file_content() to: 1. First try Test Tags extraction (preferred) 2. Fall back to filename parsing if needed 3. Log which extraction method succeeded **Example:** ```robot Test Tags ... os:ios-xe ... category:foundations ... feature:interfaces ``` Extracts "ios-xe" from Test Tags → maps to "catalog/IOS-XE/" directory **Files Changed:** - github_ops_manager/processing/test_cases_processor.py (new function) - github_ops_manager/synchronize/pull_requests.py (updated extraction logic) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../processing/test_cases_processor.py | 41 +++++++++++++++++++ .../synchronize/pull_requests.py | 16 +++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py index aac3f8a..691f915 100644 --- a/github_ops_manager/processing/test_cases_processor.py +++ b/github_ops_manager/processing/test_cases_processor.py @@ -62,9 +62,50 @@ def normalize_os_to_catalog_dir(os_name: str) -> str: return normalized +def extract_os_from_robot_content(robot_content: str) -> str | None: + """Extract OS from robot file Test Tags section. + + Looks for the os: tag in the Test Tags section of a Robot Framework file. + This is more reliable than filename parsing since tags are structured metadata. + + Args: + robot_content: Complete content of robot file + + Returns: + Extracted OS name (e.g., "ios-xe", "nx-os") or None if not found + + Example: + >>> content = ''' + ... Test Tags + ... ... os:ios-xe + ... ... category:foundations + ... ''' + >>> extract_os_from_robot_content(content) + 'ios-xe' + """ + import re + + # Regex pattern to find os: tag in Test Tags section + # Matches: os:ios-xe, os:nx-os, etc. + pattern = r"(?:^|\s)os:([a-zA-Z0-9_-]+)" + + match = re.search(pattern, robot_content, re.MULTILINE | re.IGNORECASE) + + if match: + os_value = match.group(1).lower() + logger.debug("Extracted OS from Test Tags", os=os_value) + return os_value + + logger.warning("Could not find os: tag in robot file Test Tags section") + return None + + def extract_os_from_robot_filename(filename: str) -> str | None: """Extract OS from robot filename pattern like verify_ios_xe_*.robot. + This is a fallback method if Test Tags parsing fails. + Prefer extract_os_from_robot_content() for more reliable extraction. + Args: filename: Robot filename (e.g., "verify_ios_xe_interfaces.robot") diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index 6475f44..63ceb21 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -9,6 +9,7 @@ from github_ops_manager.github.adapter import GitHubKitAdapter from github_ops_manager.processing.test_cases_processor import ( + extract_os_from_robot_content, extract_os_from_robot_filename, find_test_cases_files, load_test_cases_yaml, @@ -107,7 +108,17 @@ async def get_desired_pull_request_file_content( # Transform path if catalog workflow and file is a robot file if catalog_workflow and file.endswith(".robot"): filename = Path(file).name - os_name = extract_os_from_robot_filename(filename) + + # Try to extract OS from Test Tags in robot file content (preferred) + os_name = extract_os_from_robot_content(file_content) + extraction_method = "test_tags" + + # Fall back to filename parsing if Test Tags parsing fails + if not os_name: + logger.info("Test Tags parsing failed, falling back to filename parsing", filename=filename) + os_name = extract_os_from_robot_filename(filename) + extraction_method = "filename" + if os_name: catalog_dir = normalize_os_to_catalog_dir(os_name) catalog_path = f"catalog/{catalog_dir}/{filename}" @@ -117,10 +128,11 @@ async def get_desired_pull_request_file_content( catalog_path=catalog_path, os_name=os_name, catalog_dir=catalog_dir, + extraction_method=extraction_method, ) files.append((catalog_path, file_content)) else: - logger.warning("Could not extract OS from robot filename, using original path", filename=filename) + logger.warning("Could not extract OS from robot file, using original path", filename=filename) files.append((file, file_content)) else: files.append((file, file_content)) From eda2d248e3cb07581f2ec5ab91c0d33bb249735c Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Tue, 28 Oct 2025 18:08:32 -0400 Subject: [PATCH 05/22] refactor: simplify regex pattern for OS tag extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed pattern from [a-zA-Z0-9_-]+ to \S+ for more flexible matching. \S+ matches any non-whitespace character, which is simpler and handles more edge cases than the explicit character class. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/processing/test_cases_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py index 691f915..8345141 100644 --- a/github_ops_manager/processing/test_cases_processor.py +++ b/github_ops_manager/processing/test_cases_processor.py @@ -87,7 +87,7 @@ def extract_os_from_robot_content(robot_content: str) -> str | None: # Regex pattern to find os: tag in Test Tags section # Matches: os:ios-xe, os:nx-os, etc. - pattern = r"(?:^|\s)os:([a-zA-Z0-9_-]+)" + pattern = r"(?:^|\s)os:(\S+)" match = re.search(pattern, robot_content, re.MULTILINE | re.IGNORECASE) From 7d3dfd2a70291fd282383b55b81fb747da06494c Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Tue, 28 Oct 2025 18:23:09 -0400 Subject: [PATCH 06/22] fix: add console script entry point for CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added [project.scripts] section to pyproject.toml to expose the CLI as a console script. This allows the package to be invoked via: - uv run github-ops-manager - github-ops-manager (after installation) Without this entry point, the CLI was not accessible as a command after package installation. Entry point: github-ops-manager -> github_ops_manager.configuration.cli:typer_app 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index dbee723..3b16df6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ docs = [ "mkdocs-git-revision-date-localized-plugin>=1.2.0", ] +[project.scripts] +github-ops-manager = "github_ops_manager.configuration.cli:typer_app" + [project.urls] "Homepage" = "https://github.com/aitestino/github-ops-manager.git" "Bug Tracker" = "https://github.com/aitestino/github-ops-manager.git/issues" From c00c35ad34244e75cc0800ef0d0f29ccc4fd149e Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Tue, 28 Oct 2025 18:44:34 -0400 Subject: [PATCH 07/22] fix: use non-recursive glob to avoid backup files in test_cases_dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed find_test_cases_files() to use non-recursive globbing (*.yaml instead of **/*.yaml) to avoid picking up backup files in subdirectories like .backups/ **Problem:** Recursive glob pattern would find files like: - workspace/test_cases/test_cases.yaml ✓ - workspace/test_cases/.backups/test_cases_old.yaml ✗ (unwanted) **Solution:** Only search immediate directory, not subdirectories: - Before: test_cases_dir.glob('**/*.yaml') # Recursive - After: test_cases_dir.glob('*.yaml') # Non-recursive This prevents processing stale/backup test case files that could cause weird behavior or duplicate PR creation attempts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/processing/test_cases_processor.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py index 8345141..fc8ce94 100644 --- a/github_ops_manager/processing/test_cases_processor.py +++ b/github_ops_manager/processing/test_cases_processor.py @@ -144,7 +144,10 @@ def extract_os_from_robot_filename(filename: str) -> str | None: def find_test_cases_files(test_cases_dir: Path) -> list[Path]: - """Find all test_cases.yaml files in directory. + """Find all test_cases.yaml files in directory (non-recursive). + + Only searches the immediate directory to avoid picking up backup files + in subdirectories like .backups/ Args: test_cases_dir: Directory to search for test case files @@ -156,8 +159,8 @@ def find_test_cases_files(test_cases_dir: Path) -> list[Path]: logger.error("Test cases directory does not exist", test_cases_dir=str(test_cases_dir)) return [] - # Look for .yaml and .yml files - yaml_files = list(test_cases_dir.glob("**/*.yaml")) + list(test_cases_dir.glob("**/*.yml")) + # Look for .yaml and .yml files in immediate directory only (non-recursive) + yaml_files = list(test_cases_dir.glob("*.yaml")) + list(test_cases_dir.glob("*.yml")) # Filter for files that likely contain test cases test_case_files = [] From 95a0f9cc7df1fbff440f9a7771545ab65ada18dc Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Tue, 28 Oct 2025 19:08:37 -0400 Subject: [PATCH 08/22] refactor: separate catalog PR workflow from issues workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This refactoring addresses the architectural issue where catalog-destined test cases were incorrectly trying to create issues+PRs in the catalog repo. The new architecture creates standalone PRs for catalog without issues. Changes: - Add load_catalog_destined_test_cases() to read test_cases.yaml directly - Add create_catalog_pull_requests() for standalone catalog PR creation - Update driver.py to call catalog PR function after issues workflow - Simplify sync_github_pull_requests() to filter out catalog-destined - Fix base_directory path resolution (use test_cases_dir.parent) Workflow now supports: 1. Non-catalog test cases: Create issues+PRs in project repo 2. Catalog-destined test cases: Create standalone PRs in catalog repo Fixes #22 #23 #24 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../processing/test_cases_processor.py | 42 ++++ github_ops_manager/synchronize/driver.py | 46 +++- .../synchronize/pull_requests.py | 233 ++++++++++++------ 3 files changed, 250 insertions(+), 71 deletions(-) diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py index fc8ce94..a428bbc 100644 --- a/github_ops_manager/processing/test_cases_processor.py +++ b/github_ops_manager/processing/test_cases_processor.py @@ -262,3 +262,45 @@ def update_test_case_with_pr_metadata(test_case: dict[str, Any], pr: PullRequest ) return test_case + + +def load_catalog_destined_test_cases(test_cases_dir: Path) -> list[dict[str, Any]]: + """Load test cases that are catalog-destined from test_cases.yaml files. + + Args: + test_cases_dir: Directory containing test_cases.yaml files + + Returns: + List of test case dictionaries with catalog_destined=true + """ + catalog_test_cases = [] + test_case_files = find_test_cases_files(test_cases_dir) + + for test_case_file in test_case_files: + data = load_test_cases_yaml(test_case_file) + if not data or "test_cases" not in data: + continue + + test_cases = data["test_cases"] + if not isinstance(test_cases, list): + logger.warning("test_cases field is not a list", filepath=str(test_case_file)) + continue + + # Filter for catalog-destined test cases with generated scripts + for test_case in test_cases: + is_catalog = test_case.get("catalog_destined", False) + has_script = test_case.get("generated_script_path") + + if is_catalog and has_script: + # Add metadata about source file for later writeback + test_case["_source_file"] = str(test_case_file) + catalog_test_cases.append(test_case) + logger.debug( + "Found catalog-destined test case", + title=test_case.get("title"), + script=has_script, + source_file=str(test_case_file), + ) + + logger.info("Loaded catalog-destined test cases", count=len(catalog_test_cases), test_cases_dir=str(test_cases_dir)) + return catalog_test_cases diff --git a/github_ops_manager/synchronize/driver.py b/github_ops_manager/synchronize/driver.py index 75f2d61..60f5c1b 100644 --- a/github_ops_manager/synchronize/driver.py +++ b/github_ops_manager/synchronize/driver.py @@ -10,7 +10,7 @@ from github_ops_manager.github.adapter import GitHubKitAdapter from github_ops_manager.processing.yaml_processor import YAMLProcessingError, YAMLProcessor from github_ops_manager.synchronize.issues import render_issue_bodies, sync_github_issues -from github_ops_manager.synchronize.pull_requests import sync_github_pull_requests +from github_ops_manager.synchronize.pull_requests import create_catalog_pull_requests, sync_github_pull_requests from github_ops_manager.synchronize.results import AllIssueSynchronizationResults, ProcessIssuesResult logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) @@ -156,4 +156,48 @@ async def run_process_issues_workflow( end_time = time.time() total_time = end_time - start_time logger.info("Processed pull requests", start_time=start_time, end_time=end_time, duration=round(total_time, 2)) + + # Create standalone catalog PRs (no issues) for catalog-destined test cases + if test_cases_dir.exists(): + logger.info("Processing catalog-destined test cases from test_cases.yaml files", test_cases_dir=str(test_cases_dir)) + + # Create adapter for catalog repository + catalog_adapter = await GitHubKitAdapter.create( + repo=catalog_repo, + github_auth_type=github_auth_type, + github_pat_token=github_pat_token, + github_app_id=github_app_id, + github_app_private_key_path=github_app_private_key_path, + github_app_installation_id=github_app_installation_id, + github_api_url=github_api_url, + ) + + # Get catalog repository info + catalog_repo_info = await catalog_adapter.get_repository() + catalog_default_branch = catalog_repo_info.default_branch + + # Base directory for resolving robot file paths + # Robot files are typically in workspace/ directory, which is parent of test_cases_dir + base_directory = test_cases_dir.parent if test_cases_dir.name != "." else test_cases_dir + + logger.info( + "Creating catalog PRs", + catalog_repo=catalog_repo, + catalog_default_branch=catalog_default_branch, + base_directory=str(base_directory), + ) + + start_catalog_time = time.time() + await create_catalog_pull_requests( + test_cases_dir=test_cases_dir, + base_directory=base_directory, + catalog_repo=catalog_repo, + catalog_repo_url=catalog_repo_url, + catalog_default_branch=catalog_default_branch, + github_adapter=catalog_adapter, + ) + end_catalog_time = time.time() + catalog_duration = end_catalog_time - start_catalog_time + logger.info("Completed catalog PR creation", duration=round(catalog_duration, 2)) + return ProcessIssuesResult(issue_sync_results) diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index 63ceb21..e141426 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -12,6 +12,7 @@ extract_os_from_robot_content, extract_os_from_robot_filename, find_test_cases_files, + load_catalog_destined_test_cases, load_test_cases_yaml, normalize_os_to_catalog_dir, save_test_cases_yaml, @@ -410,6 +411,152 @@ async def sync_github_pull_request( await commit_files_to_branch(desired_issue, existing_issue, desired_branch_name, base_directory, github_adapter, catalog_workflow) +async def create_catalog_pull_requests( + test_cases_dir: Path, + base_directory: Path, + catalog_repo: str, + catalog_repo_url: str, + catalog_default_branch: str, + github_adapter: GitHubKitAdapter, +) -> None: + """Create standalone PRs for catalog-destined test cases (no issues). + + Reads test_cases.yaml files directly and creates PRs in catalog repository + without creating issues. This is simpler than the full issue/PR workflow. + + Args: + test_cases_dir: Directory containing test_cases.yaml files + base_directory: Base directory where robot files are located + catalog_repo: Catalog repository name (owner/repo) + catalog_repo_url: Full URL to catalog repository + catalog_default_branch: Default branch in catalog repository + github_adapter: GitHub adapter for catalog repository + """ + logger.info("Creating standalone catalog PRs", test_cases_dir=str(test_cases_dir), catalog_repo=catalog_repo) + + # Load catalog-destined test cases from test_cases.yaml files + catalog_test_cases = load_catalog_destined_test_cases(test_cases_dir) + + if not catalog_test_cases: + logger.info("No catalog-destined test cases found") + return + + logger.info("Found catalog-destined test cases to process", count=len(catalog_test_cases)) + + for test_case in catalog_test_cases: + title = test_case.get("title", "Untitled Test Case") + script_path = test_case.get("generated_script_path") + source_file = Path(test_case.get("_source_file")) + + if not script_path: + logger.warning("Test case missing generated_script_path", title=title) + continue + + logger.info("Processing catalog test case", title=title, script_path=script_path) + + # Check if PR already exists + existing_pr_number = test_case.get("catalog_pr_number") + existing_pr_url = test_case.get("catalog_pr_url") + + if existing_pr_number and existing_pr_url: + logger.info("PR already exists for test case, skipping", title=title, pr_number=existing_pr_number, pr_url=existing_pr_url) + continue + + # Build file path + robot_file_path = base_directory / script_path + if not robot_file_path.exists(): + logger.error("Robot file not found", file=str(robot_file_path), title=title) + continue + + # Read robot file content + robot_content = robot_file_path.read_text(encoding="utf-8") + + # Extract OS from Test Tags + os_name = extract_os_from_robot_content(robot_content) + if not os_name: + logger.info("Test Tags parsing failed, falling back to filename parsing", filename=robot_file_path.name) + os_name = extract_os_from_robot_filename(robot_file_path.name) + + if not os_name: + logger.error("Could not extract OS from robot file", file=str(robot_file_path), title=title) + continue + + # Transform path for catalog + catalog_dir = normalize_os_to_catalog_dir(os_name) + catalog_path = f"catalog/{catalog_dir}/{robot_file_path.name}" + + logger.info( + "Transformed robot file path for catalog", + original_path=str(script_path), + catalog_path=catalog_path, + os_name=os_name, + catalog_dir=catalog_dir, + ) + + # Create branch name + # Use a simpler naming scheme since we don't have issue numbers + branch_name = f"catalog/{os_name}/{robot_file_path.stem}".lower().replace("_", "-") + + # Check if branch exists + if await github_adapter.branch_exists(branch_name): + logger.info("Branch already exists, skipping", branch=branch_name, title=title) + # TODO: Could update existing branch/PR here + continue + + # Create branch + logger.info("Creating branch for catalog PR", branch=branch_name, base_branch=catalog_default_branch) + await github_adapter.create_branch(branch_name, catalog_default_branch) + + # Commit file to branch + commit_message = f"feat: add {catalog_dir} test - {title}" + files_to_commit = [(catalog_path, robot_content)] + + logger.info("Committing file to branch", branch=branch_name, file=catalog_path) + await github_adapter.commit_files_to_branch(branch_name, files_to_commit, commit_message) + + # Create PR + pr_title = f"feat: add {catalog_dir} test - {title}" + pr_body = f"""Catalog contribution for test automation. + +**Test Case:** {title} +**Operating System:** {os_name.upper()} +**Script:** `{catalog_path}` + +This PR adds test automation generated by tac-quicksilver to the catalog for reuse across projects. + +🤖 Automatically generated catalog contribution""" + + logger.info("Creating catalog PR", branch=branch_name, base_branch=catalog_default_branch, title=pr_title) + new_pr = await github_adapter.create_pull_request( + title=pr_title, + head=branch_name, + base=catalog_default_branch, + body=pr_body, + ) + + logger.info("Created catalog PR", pr_number=new_pr.number, pr_url=new_pr.html_url) + + # Write PR metadata back to test_cases.yaml + logger.info("Writing PR metadata back to test case file", source_file=str(source_file)) + + # Reload the source file + data = load_test_cases_yaml(source_file) + if data and "test_cases" in data: + # Find the test case and update it + for tc in data["test_cases"]: + if tc.get("generated_script_path") == script_path: + update_test_case_with_pr_metadata(tc, new_pr, catalog_repo_url) + break + + # Save back to file + if save_test_cases_yaml(source_file, data): + logger.info("Successfully wrote PR metadata back to test case file", source_file=str(source_file)) + else: + logger.error("Failed to save test case file", source_file=str(source_file)) + + logger.info("Completed catalog PR creation", total_processed=len(catalog_test_cases)) + + async def sync_github_pull_requests( desired_issues: list[IssueModel], existing_issues: list[Issue], @@ -451,73 +598,19 @@ async def sync_github_pull_requests( github_app_installation_id: GitHub App installation ID for creating catalog adapter github_api_url: GitHub API URL for creating catalog adapter """ - from github_ops_manager.configuration.models import GitHubAuthenticationType - - desired_issues_with_prs = [issue for issue in desired_issues if issue.pull_request is not None] - - # Check if we have any catalog-destined issues - catalog_destined_issues = [issue for issue in desired_issues_with_prs if getattr(issue, "catalog_destined", False)] - - catalog_adapter = None - catalog_issues = None - catalog_prs = None - catalog_default_branch = None - - if catalog_destined_issues: - logger.info( - "Detected catalog-destined issues, creating catalog adapter", - catalog_count=len(catalog_destined_issues), - total_count=len(desired_issues_with_prs), - catalog_repo=catalog_repo, - ) - - # Create adapter for catalog repository - catalog_adapter = await GitHubKitAdapter.create( - repo=catalog_repo, - github_auth_type=GitHubAuthenticationType(github_auth_type) if github_auth_type else None, - github_pat_token=github_pat_token, - github_app_id=github_app_id, - github_app_private_key_path=github_app_private_key_path, - github_app_installation_id=github_app_installation_id, - github_api_url=github_api_url, - ) + # Filter out catalog-destined issues - they are handled separately by create_catalog_pull_requests() + desired_issues_with_prs = [issue for issue in desired_issues if issue.pull_request is not None and not getattr(issue, "catalog_destined", False)] - # Get catalog repository info - catalog_repo_info = await catalog_adapter.get_repository() - catalog_default_branch = catalog_repo_info.default_branch - - # Fetch existing issues and PRs from catalog repository - catalog_issues = await catalog_adapter.list_issues() - catalog_simple_prs = await catalog_adapter.list_pull_requests() - catalog_prs = [await catalog_adapter.get_pull_request(pr.number) for pr in catalog_simple_prs] - - logger.info( - "Fetched catalog repository state", - catalog_issues_count=len(catalog_issues), - catalog_prs_count=len(catalog_prs), - catalog_default_branch=catalog_default_branch, - ) + logger.info( + "Processing project issues with pull requests (catalog-destined filtered out)", + project_issues_count=len(desired_issues_with_prs), + ) - # Process each issue + # Process each issue (all are project issues since catalog-destined are filtered out) for desired_issue in desired_issues_with_prs: - is_catalog_destined = getattr(desired_issue, "catalog_destined", False) - - if is_catalog_destined: - # Use catalog adapter and state - adapter = catalog_adapter - issues_list = catalog_issues - prs_list = catalog_prs - branch = catalog_default_branch - logger.info("Processing catalog-destined issue", issue_title=desired_issue.title) - else: - # Use project adapter and state - adapter = github_adapter - issues_list = existing_issues - prs_list = existing_pull_requests - branch = default_branch - logger.info("Processing project issue", issue_title=desired_issue.title) - - existing_issue = next((issue for issue in issues_list if issue.title == desired_issue.title), None) + logger.info("Processing project issue", issue_title=desired_issue.title) + + existing_issue = next((issue for issue in existing_issues if issue.title == desired_issue.title), None) if existing_issue is not None: logger.info( "Existing issue found", @@ -530,17 +623,17 @@ async def sync_github_pull_requests( continue # Find existing PR associated with existing issue, if any. - existing_pr = await get_pull_request_associated_with_issue(existing_issue, prs_list) + existing_pr = await get_pull_request_associated_with_issue(existing_issue, existing_pull_requests) await sync_github_pull_request( desired_issue, existing_issue, - adapter, - branch, + github_adapter, + default_branch, base_directory, existing_pull_request=existing_pr, testing_as_code_workflow=testing_as_code_workflow, - catalog_workflow=is_catalog_destined, - catalog_repo_url=catalog_repo_url if is_catalog_destined else None, - test_cases_dir=test_cases_dir if is_catalog_destined else None, + catalog_workflow=False, # Always False for project issues + catalog_repo_url=None, + test_cases_dir=None, ) From b7db17ffd16988df475930a27f37127eea0d67a2 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 30 Oct 2025 14:04:00 -0400 Subject: [PATCH 09/22] fix: remove double slash in catalog repo URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When github_api_url ends with a trailing slash (e.g., 'https://wwwin-github.cisco.com/api/v3/'), the URL construction was creating double slashes like: https://wwwin-github.cisco.com//Testing-as-Code/tac-catalog Fixed by using .rstrip('/') to remove trailing slashes from base_url before constructing the catalog_repo_url. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/synchronize/driver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/github_ops_manager/synchronize/driver.py b/github_ops_manager/synchronize/driver.py index 60f5c1b..0d70120 100644 --- a/github_ops_manager/synchronize/driver.py +++ b/github_ops_manager/synchronize/driver.py @@ -129,8 +129,8 @@ async def run_process_issues_workflow( if "api.github.com" in github_api_url: base_url = "https://github.com" else: - # For GitHub Enterprise, remove /api/v3 suffix - base_url = github_api_url.replace("/api/v3", "").replace("/api", "") + # For GitHub Enterprise, remove /api/v3 suffix and any trailing slashes + base_url = github_api_url.replace("/api/v3", "").replace("/api", "").rstrip("/") catalog_repo_url = f"{base_url}/{catalog_repo}" await sync_github_pull_requests( From 48e29e895eb97d4e580d9d0f2c8f3db8ac9a0287 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 30 Oct 2025 15:25:35 -0400 Subject: [PATCH 10/22] feat: use conventional branch naming for catalog PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed catalog branch naming from: catalog/{os_name}/{script_name} To: feat/add-{script_name} This follows Git best practices with conventional commit/branch naming patterns. Example: Before: catalog/ios-xe/verify-iosxe-error-disable-detection-reason-presence After: feat/add-verify-iosxe-error-disable-detection-reason-presence 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/synchronize/pull_requests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index e141426..9d54384 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -493,9 +493,9 @@ async def create_catalog_pull_requests( catalog_dir=catalog_dir, ) - # Create branch name - # Use a simpler naming scheme since we don't have issue numbers - branch_name = f"catalog/{os_name}/{robot_file_path.stem}".lower().replace("_", "-") + # Create branch name following conventional Git naming patterns + # feat/add- (e.g., feat/add-verify-iosxe-error-disable-detection-reason-presence) + branch_name = f"feat/add-{robot_file_path.stem}".lower().replace("_", "-") # Check if branch exists if await github_adapter.branch_exists(branch_name): From dfd519a3efefdb0fac36de7a75e08c3c2f19d8bc Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 30 Oct 2025 15:30:10 -0400 Subject: [PATCH 11/22] refactor: include OS name in catalog branch naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed catalog branch naming to include OS for better organization: feat/{os_name}/add-{script_name} This groups branches by operating system, making it easier to manage catalog contributions in the repository. Example: feat/ios-xe/add-verify-iosxe-error-disable-detection-reason-presence feat/nxos/add-verify-nxos-vlan-configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/synchronize/pull_requests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index 9d54384..150f84d 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -494,8 +494,8 @@ async def create_catalog_pull_requests( ) # Create branch name following conventional Git naming patterns - # feat/add- (e.g., feat/add-verify-iosxe-error-disable-detection-reason-presence) - branch_name = f"feat/add-{robot_file_path.stem}".lower().replace("_", "-") + # feat//add- (e.g., feat/ios-xe/add-verify-iosxe-error-disable-detection-reason-presence) + branch_name = f"feat/{os_name}/add-{robot_file_path.stem}".lower().replace("_", "-") # Check if branch exists if await github_adapter.branch_exists(branch_name): From 993afcb4f23111030b3e613c29cac83b346e6557 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 6 Nov 2025 23:18:26 -0500 Subject: [PATCH 12/22] feat: add tracking issue creation for catalog PRs - Add new CLI parameters --create-tracking-issues and --tracking-issue-labels - Create synchronize/tracking_issues.py module with issue creation logic - Add Jinja2 template for tracking issue body (templates/tracking_issue.j2) - Update create_catalog_pull_requests() to return PR metadata - Add writeback of project_issue_number and project_issue_url to test_cases.yaml - Update driver.py to conditionally create tracking issues after catalog PRs - Add update_test_case_with_issue_metadata() helper function This enables automatic creation of project repository issues that track catalog PR review and parameter learning tasks, with full traceability via metadata writeback to test_cases.yaml files. --- github_ops_manager/configuration/cli.py | 23 +++ .../processing/test_cases_processor.py | 23 +++ github_ops_manager/synchronize/driver.py | 28 ++- .../synchronize/pull_requests.py | 30 ++- .../synchronize/tracking_issues.py | 177 ++++++++++++++++++ .../templates/tracking_issue.j2 | 19 ++ 6 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 github_ops_manager/synchronize/tracking_issues.py create mode 100644 github_ops_manager/templates/tracking_issue.j2 diff --git a/github_ops_manager/configuration/cli.py b/github_ops_manager/configuration/cli.py index 41246d5..e945984 100644 --- a/github_ops_manager/configuration/cli.py +++ b/github_ops_manager/configuration/cli.py @@ -218,12 +218,28 @@ def process_issues_cli( help="Directory containing test_cases.yaml files for catalog PR metadata writeback. Used when test cases have catalog_destined=true.", ), ] = Path("workspace/test_cases/"), + create_tracking_issues: Annotated[ + bool, + Option( + envvar="CREATE_TRACKING_ISSUES", + help="Create tracking issues in project repo for catalog PRs and parameter learning tasks.", + ), + ] = False, + tracking_issue_labels: Annotated[ + str | None, + Option( + envvar="TRACKING_ISSUE_LABELS", + help="Comma-separated list of labels to apply to tracking issues (e.g., 'parameter-learning,catalog-pr').", + ), + ] = None, ) -> None: """Processes issues in a GitHub repository. Automatically detects catalog-destined test cases (catalog_destined=true) and creates PRs against the catalog repository with proper directory structure and metadata writeback. Non-catalog test cases are processed normally against the project repository. + + Optionally creates tracking issues for catalog PRs to track parameter learning tasks. """ repo: str = ctx.obj["repo"] github_api_url: str = ctx.obj["github_api_url"] @@ -236,6 +252,11 @@ def process_issues_cli( if testing_as_code_workflow is True: typer.echo("Testing as Code workflow is enabled - any Pull Requests created will have an augmented body") + # Parse tracking issue labels from comma-separated string + parsed_labels = None + if tracking_issue_labels: + parsed_labels = [label.strip() for label in tracking_issue_labels.split(",") if label.strip()] + # Run the workflow result = asyncio.run( run_process_issues_workflow( @@ -250,6 +271,8 @@ def process_issues_cli( testing_as_code_workflow=testing_as_code_workflow, catalog_repo=catalog_repo, test_cases_dir=test_cases_dir, + create_tracking_issues=create_tracking_issues, + tracking_issue_labels=parsed_labels, ) ) if result.errors: diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py index a428bbc..2e5ec50 100644 --- a/github_ops_manager/processing/test_cases_processor.py +++ b/github_ops_manager/processing/test_cases_processor.py @@ -264,6 +264,29 @@ def update_test_case_with_pr_metadata(test_case: dict[str, Any], pr: PullRequest return test_case +def update_test_case_with_issue_metadata(test_case: dict[str, Any], issue_number: int, issue_url: str) -> dict[str, Any]: + """Add project issue metadata fields to test case. + + Args: + test_case: Test case dictionary to update + issue_number: GitHub Issue number + issue_url: GitHub Issue URL + + Returns: + Updated test case dictionary + """ + test_case["project_issue_number"] = issue_number + test_case["project_issue_url"] = issue_url + + logger.info( + "Updated test case with project issue metadata", + project_issue_number=issue_number, + project_issue_url=issue_url, + ) + + return test_case + + def load_catalog_destined_test_cases(test_cases_dir: Path) -> list[dict[str, Any]]: """Load test cases that are catalog-destined from test_cases.yaml files. diff --git a/github_ops_manager/synchronize/driver.py b/github_ops_manager/synchronize/driver.py index 0d70120..a48528e 100644 --- a/github_ops_manager/synchronize/driver.py +++ b/github_ops_manager/synchronize/driver.py @@ -12,6 +12,7 @@ from github_ops_manager.synchronize.issues import render_issue_bodies, sync_github_issues from github_ops_manager.synchronize.pull_requests import create_catalog_pull_requests, sync_github_pull_requests from github_ops_manager.synchronize.results import AllIssueSynchronizationResults, ProcessIssuesResult +from github_ops_manager.synchronize.tracking_issues import create_tracking_issues_for_catalog_prs logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) @@ -29,6 +30,8 @@ async def run_process_issues_workflow( testing_as_code_workflow: bool = False, catalog_repo: str = "Testing-as-Code/tac-catalog", test_cases_dir: Path = Path("workspace/test_cases/"), + create_tracking_issues: bool = False, + tracking_issue_labels: list[str] | None = None, ) -> ProcessIssuesResult: """Run the process-issues workflow: load issues from YAML and return them/errors. @@ -188,7 +191,7 @@ async def run_process_issues_workflow( ) start_catalog_time = time.time() - await create_catalog_pull_requests( + catalog_pr_data = await create_catalog_pull_requests( test_cases_dir=test_cases_dir, base_directory=base_directory, catalog_repo=catalog_repo, @@ -200,4 +203,27 @@ async def run_process_issues_workflow( catalog_duration = end_catalog_time - start_catalog_time logger.info("Completed catalog PR creation", duration=round(catalog_duration, 2)) + # Create tracking issues in project repo for catalog PRs + if create_tracking_issues and catalog_pr_data: + logger.info( + "Creating tracking issues in project repository", + catalog_pr_count=len(catalog_pr_data), + repo=repo, + ) + + start_tracking_time = time.time() + tracking_issues = await create_tracking_issues_for_catalog_prs( + github_adapter=github_adapter, # Project repo adapter + catalog_pr_data=catalog_pr_data, + catalog_repo=catalog_repo, + labels=tracking_issue_labels, + ) + end_tracking_time = time.time() + tracking_duration = end_tracking_time - start_tracking_time + logger.info( + "Completed tracking issue creation", + duration=round(tracking_duration, 2), + issues_created=len(tracking_issues), + ) + return ProcessIssuesResult(issue_sync_results) diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index 150f84d..cb00953 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -2,6 +2,7 @@ import re from pathlib import Path +from typing import Any import structlog from githubkit.versions.latest.models import Issue, PullRequest @@ -418,7 +419,7 @@ async def create_catalog_pull_requests( catalog_repo_url: str, catalog_default_branch: str, github_adapter: GitHubKitAdapter, -) -> None: +) -> list[dict[str, Any]]: """Create standalone PRs for catalog-destined test cases (no issues). Reads test_cases.yaml files directly and creates PRs in catalog repository @@ -431,6 +432,9 @@ async def create_catalog_pull_requests( catalog_repo_url: Full URL to catalog repository catalog_default_branch: Default branch in catalog repository github_adapter: GitHub adapter for catalog repository + + Returns: + List of dicts with keys: pr, test_cases, os_name, branch_name, catalog_path """ logger.info("Creating standalone catalog PRs", test_cases_dir=str(test_cases_dir), catalog_repo=catalog_repo) @@ -439,10 +443,13 @@ async def create_catalog_pull_requests( if not catalog_test_cases: logger.info("No catalog-destined test cases found") - return + return [] logger.info("Found catalog-destined test cases to process", count=len(catalog_test_cases)) + # Accumulate created PR data for tracking issue creation + created_pr_data = [] + for test_case in catalog_test_cases: title = test_case.get("title", "Untitled Test Case") script_path = test_case.get("generated_script_path") @@ -536,6 +543,17 @@ async def create_catalog_pull_requests( logger.info("Created catalog PR", pr_number=new_pr.number, pr_url=new_pr.html_url) + # Store PR data for tracking issue creation + created_pr_data.append( + { + "pr": new_pr, + "test_cases": [test_case], + "os_name": os_name, + "branch_name": branch_name, + "catalog_path": catalog_path, + } + ) + # Write PR metadata back to test_cases.yaml logger.info("Writing PR metadata back to test case file", source_file=str(source_file)) @@ -554,7 +572,13 @@ async def create_catalog_pull_requests( else: logger.error("Failed to save test case file", source_file=str(source_file)) - logger.info("Completed catalog PR creation", total_processed=len(catalog_test_cases)) + logger.info( + "Completed catalog PR creation", + total_processed=len(catalog_test_cases), + prs_created=len(created_pr_data), + ) + + return created_pr_data async def sync_github_pull_requests( diff --git a/github_ops_manager/synchronize/tracking_issues.py b/github_ops_manager/synchronize/tracking_issues.py new file mode 100644 index 0000000..a4d3780 --- /dev/null +++ b/github_ops_manager/synchronize/tracking_issues.py @@ -0,0 +1,177 @@ +"""Contains logic for creating tracking issues for catalog PRs and parameter learning tasks.""" + +from pathlib import Path +from typing import Any + +import structlog +from githubkit.versions.latest.models import Issue, PullRequest +from jinja2 import Environment, FileSystemLoader, Template + +from github_ops_manager.github.adapter import GitHubKitAdapter +from github_ops_manager.processing.test_cases_processor import ( + load_test_cases_yaml, + save_test_cases_yaml, + update_test_case_with_issue_metadata, +) + +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# Set up Jinja2 environment for loading templates +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" +jinja_env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=False) + + +def load_tracking_issue_template() -> Template: + """Load the tracking issue template from disk. + + Returns: + Jinja2 Template object + """ + return jinja_env.get_template("tracking_issue.j2") + + +async def create_tracking_issue_for_catalog_pr( + github_adapter: GitHubKitAdapter, + catalog_pr: PullRequest, + test_cases: list[dict[str, Any]], + os_name: str, + catalog_repo: str, + labels: list[str] | None = None, +) -> Issue: + """Create a tracking issue in project repo for a catalog PR. + + Args: + github_adapter: GitHub adapter for project repository + catalog_pr: The catalog PR that was created + test_cases: List containing single test case dict (always one test case per catalog PR) + os_name: Operating system name (e.g., "ios-xe", "nxos") + catalog_repo: Catalog repository name (owner/repo) + labels: Optional list of label names to apply to the issue + + Returns: + Created Issue object + """ + # Build issue title + # Since each catalog PR contains exactly one test case, get the test case title + test_case = test_cases[0] + test_case_title = test_case.get("title", "Untitled Test Case") + + title = f"Review Catalog PR and Learn Parameters: {test_case_title}" + + # Load and render the tracking issue template + template = load_tracking_issue_template() + body = template.render( + catalog_pr_title=catalog_pr.title, + catalog_pr_url=catalog_pr.html_url, + catalog_pr_number=catalog_pr.number, + catalog_branch=catalog_pr.head.ref, + test_case_title=test_case_title, + os_name=os_name.upper(), + ) + + logger.info( + "Creating tracking issue in project repository", + catalog_pr_number=catalog_pr.number, + catalog_pr_url=catalog_pr.html_url, + test_case_title=test_case_title, + os_name=os_name, + ) + + # Create the issue + issue = await github_adapter.create_issue( + title=title, + body=body, + ) + + # Apply labels if provided + if labels: + logger.info("Applying labels to tracking issue", issue_number=issue.number, labels=labels) + await github_adapter.set_labels_on_issue(issue.number, labels) + + logger.info( + "Created tracking issue", + issue_number=issue.number, + issue_url=issue.html_url, + catalog_pr_number=catalog_pr.number, + ) + + # Write issue metadata back to test_cases.yaml + source_file_path = test_case.get("_source_file") + if source_file_path: + source_file = Path(source_file_path) + logger.info("Writing project issue metadata back to test case file", source_file=str(source_file)) + + # Reload the source file + data = load_test_cases_yaml(source_file) + if data and "test_cases" in data: + # Find the test case and update it + # Match by title since that's unique and reliable + for tc in data["test_cases"]: + if tc.get("title") == test_case_title: + update_test_case_with_issue_metadata(tc, issue.number, issue.html_url) + break + + # Save back to file + if save_test_cases_yaml(source_file, data): + logger.info("Successfully wrote project issue metadata back to test case file", source_file=str(source_file)) + else: + logger.error("Failed to save test case file", source_file=str(source_file)) + else: + logger.warning("Could not load test cases from source file", source_file=str(source_file)) + else: + logger.warning("Test case missing _source_file metadata, cannot write back issue metadata", test_case_title=test_case_title) + + return issue + + +async def create_tracking_issues_for_catalog_prs( + github_adapter: GitHubKitAdapter, + catalog_pr_data: list[dict[str, Any]], + catalog_repo: str, + labels: list[str] | None = None, +) -> list[Issue]: + """Create tracking issues for all catalog PRs that were created. + + Args: + github_adapter: GitHub adapter for project repository + catalog_pr_data: List of dicts with keys: pr, test_cases, os_name + catalog_repo: Catalog repository name (owner/repo) + labels: Optional list of label names to apply to issues + + Returns: + List of created Issue objects + """ + if not catalog_pr_data: + logger.info("No catalog PR data provided, skipping tracking issue creation") + return [] + + logger.info("Creating tracking issues for catalog PRs", count=len(catalog_pr_data), catalog_repo=catalog_repo) + + created_issues = [] + + for pr_data in catalog_pr_data: + pr = pr_data["pr"] + test_cases = pr_data["test_cases"] + os_name = pr_data["os_name"] + + try: + issue = await create_tracking_issue_for_catalog_pr( + github_adapter=github_adapter, + catalog_pr=pr, + test_cases=test_cases, + os_name=os_name, + catalog_repo=catalog_repo, + labels=labels, + ) + created_issues.append(issue) + except Exception as e: + logger.error( + "Failed to create tracking issue for catalog PR", + catalog_pr_number=pr.number, + error=str(e), + exc_info=True, + ) + + logger.info("Completed tracking issue creation", created_count=len(created_issues), total_prs=len(catalog_pr_data)) + + return created_issues diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 new file mode 100644 index 0000000..71664a0 --- /dev/null +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -0,0 +1,19 @@ +## Catalog PR: {{ catalog_pr_title }} + +**Test Case**: {{ test_case_title }} +**Catalog PR**: {{ catalog_pr_url }} +**Catalog Branch**: `{{ catalog_branch }}` +**Operating System**: {{ os_name }} + +### Tasks + +- [ ] Create a new branch in this project repository to track your test case parameter additions +- [ ] Review the Catalog PR for any glaringly obvious issues +- [ ] Execute `tac-tools scripts learn "{{ test_case_title }}"` 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 +- [ ] Execute `tac-tools scripts run "{{ test_case_title }}"` 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 +- [ ] When the test automation script in the Catalog repository is in a known working condition, assign a Catalog reviewer to review the PR +- [ ] Submit a PR in this project repository tracking your test case parameters. **Make sure to link the PR to this issue so that this issue is automatically closed when the PR is merged** +- [ ] Either have the PR reviewed by another engineer (recommended) or merge the PR yourself, depending on your project and team's established processes + +--- +🤖 Automatically generated tracking issue for catalog PR #{{ catalog_pr_number }} From ff12796928e79c7a63eacfac6bc9a29a1855700b Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Fri, 7 Nov 2025 09:19:51 -0500 Subject: [PATCH 13/22] fix: strip OS tags from test case titles in CLI commands Test case titles in test_cases.yaml may include OS tag prefixes like [IOS-XE] or [NX-OS], but these tags are stripped when creating test case groups in cxtm.yaml. The tracking issue template now uses the clean title (without OS tags) in CLI commands so that scripts learn and scripts run commands target the correct test case group names. - Add strip_os_tag_from_title() helper function - Pass both original and clean titles to template - Update template to use clean title in CLI commands - Keep original title for display purposes --- .../synchronize/tracking_issues.py | 35 ++++++++++++++++++- .../templates/tracking_issue.j2 | 4 +-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/github_ops_manager/synchronize/tracking_issues.py b/github_ops_manager/synchronize/tracking_issues.py index a4d3780..453989e 100644 --- a/github_ops_manager/synchronize/tracking_issues.py +++ b/github_ops_manager/synchronize/tracking_issues.py @@ -30,6 +30,34 @@ def load_tracking_issue_template() -> Template: return jinja_env.get_template("tracking_issue.j2") +def strip_os_tag_from_title(title: str) -> str: + """Strip OS tag prefix from test case title. + + Removes leading OS tags like [IOS-XE], [NX-OS], etc. from the title + to get the clean test case group name that appears in cxtm.yaml. + + Args: + title: Test case title potentially with OS tag prefix + + Returns: + Title without OS tag prefix + + Examples: + >>> strip_os_tag_from_title("[IOS-XE] Verify Interface Status") + "Verify Interface Status" + >>> strip_os_tag_from_title("[NX-OS] Check BGP Neighbors") + "Check BGP Neighbors" + >>> strip_os_tag_from_title("Verify LLDP on all devices") + "Verify LLDP on all devices" + """ + import re + + # Pattern matches [ANYTHING] at the start of the string, followed by optional whitespace + pattern = r"^\[.*?\]\s*" + cleaned_title = re.sub(pattern, "", title) + return cleaned_title + + async def create_tracking_issue_for_catalog_pr( github_adapter: GitHubKitAdapter, catalog_pr: PullRequest, @@ -56,6 +84,10 @@ 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) + title = f"Review Catalog PR and Learn Parameters: {test_case_title}" # Load and render the tracking issue template @@ -65,7 +97,8 @@ async def create_tracking_issue_for_catalog_pr( catalog_pr_url=catalog_pr.html_url, catalog_pr_number=catalog_pr.number, catalog_branch=catalog_pr.head.ref, - test_case_title=test_case_title, + 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(), ) diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index 71664a0..c7235d2 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -9,8 +9,8 @@ - [ ] Create a new branch in this project repository to track your test case parameter additions - [ ] Review the Catalog PR for any glaringly obvious issues -- [ ] Execute `tac-tools scripts learn "{{ test_case_title }}"` 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 -- [ ] Execute `tac-tools scripts run "{{ test_case_title }}"` 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 +- [ ] Execute `tac-tools scripts learn "{{ test_case_title_clean }}"` 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 +- [ ] Execute `tac-tools scripts run "{{ test_case_title_clean }}"` 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 - [ ] When the test automation script in the Catalog repository is in a known working condition, assign a Catalog reviewer to review the PR - [ ] Submit a PR in this project repository tracking your test case parameters. **Make sure to link the PR to this issue so that this issue is automatically closed when the PR is merged** - [ ] Either have the PR reviewed by another engineer (recommended) or merge the PR yourself, depending on your project and team's established processes From f4861130647e0d8c417bf95078846b2af1428f77 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Fri, 7 Nov 2025 09:35:21 -0500 Subject: [PATCH 14/22] feat: suggest project branch name based on catalog branch Compute and display a suggested project repository branch name in tracking issues by replacing 'feat/' or 'feature/' prefix with 'learn/' from the catalog branch name. This provides users with a consistent naming convention for parameter learning branches. Examples: feat/nx-os/add-verify-nxos-module-port-number -> learn/nx-os/add-verify-nxos-module-port-number - Add compute_project_branch_name() helper function - Pass suggested_project_branch to template - Display suggestion in first task item --- .../synchronize/tracking_issues.py | 33 +++++++++++++++++++ .../templates/tracking_issue.j2 | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/github_ops_manager/synchronize/tracking_issues.py b/github_ops_manager/synchronize/tracking_issues.py index 453989e..78e67ee 100644 --- a/github_ops_manager/synchronize/tracking_issues.py +++ b/github_ops_manager/synchronize/tracking_issues.py @@ -58,6 +58,35 @@ def strip_os_tag_from_title(title: str) -> str: return cleaned_title +def compute_project_branch_name(catalog_branch: str) -> str: + """Compute suggested project repository branch name from catalog branch. + + Replaces 'feat/' prefix with 'learn/' to indicate parameter learning branch. + + Args: + catalog_branch: Catalog repository branch name + + Returns: + Suggested project repository branch name + + Examples: + >>> compute_project_branch_name("feat/nx-os/add-verify-nxos-module-port-number") + "learn/nx-os/add-verify-nxos-module-port-number" + >>> compute_project_branch_name("feat/ios-xe/add-verify-iosxe-interface-status") + "learn/ios-xe/add-verify-iosxe-interface-status" + >>> compute_project_branch_name("feature/test") + "learn/test" + """ + # Replace feat/ or feature/ prefix with learn/ + if catalog_branch.startswith("feat/"): + return catalog_branch.replace("feat/", "learn/", 1) + elif catalog_branch.startswith("feature/"): + return catalog_branch.replace("feature/", "learn/", 1) + else: + # If no feat/feature prefix, just prepend learn/ + return f"learn/{catalog_branch}" + + async def create_tracking_issue_for_catalog_pr( github_adapter: GitHubKitAdapter, catalog_pr: PullRequest, @@ -88,6 +117,9 @@ async def create_tracking_issue_for_catalog_pr( # 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) + title = f"Review Catalog PR and Learn Parameters: {test_case_title}" # Load and render the tracking issue template @@ -97,6 +129,7 @@ async def create_tracking_issue_for_catalog_pr( catalog_pr_url=catalog_pr.html_url, catalog_pr_number=catalog_pr.number, 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(), diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index c7235d2..3f42279 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -7,7 +7,7 @@ ### Tasks -- [ ] Create a new branch in this project repository to track your test case parameter additions +- [ ] Create a new branch in this project repository to track your test case parameter additions (suggested: `{{ suggested_project_branch }}`) - [ ] Review the Catalog PR for any glaringly obvious issues - [ ] Execute `tac-tools scripts learn "{{ test_case_title_clean }}"` 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 - [ ] Execute `tac-tools scripts run "{{ test_case_title_clean }}"` 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 From e5222aed78677dc466f391d7b6dcfcee5325fc6d Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Fri, 7 Nov 2025 09:39:34 -0500 Subject: [PATCH 15/22] refactor: use fenced code blocks for copyable commands Replace inline code blocks with fenced code blocks (triple backticks) for all CLI commands in the tracking issue template. This enables GitHub's copy button feature, making it easier for users to copy and paste: - git checkout command for suggested branch name - tac-tools scripts learn command - tac-tools scripts run command Each command is now in its own fenced block with bash syntax highlighting. --- .../templates/tracking_issue.j2 | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index 3f42279..8b7d031 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -7,12 +7,27 @@ ### Tasks -- [ ] Create a new branch in this project repository to track your test case parameter additions (suggested: `{{ suggested_project_branch }}`) +- [ ] Create a new branch in this project repository to track your test case parameter additions. Suggested branch name: + ```bash + git checkout -b {{ suggested_project_branch }} + ``` + - [ ] Review the Catalog PR for any glaringly obvious issues -- [ ] Execute `tac-tools scripts learn "{{ test_case_title_clean }}"` 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 -- [ ] Execute `tac-tools scripts run "{{ test_case_title_clean }}"` 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 + +- [ ] 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 }}" + ``` + +- [ ] 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 }}" + ``` + - [ ] When the test automation script in the Catalog repository is in a known working condition, assign a Catalog reviewer to review the PR + - [ ] Submit a PR in this project repository tracking your test case parameters. **Make sure to link the PR to this issue so that this issue is automatically closed when the PR is merged** + - [ ] Either have the PR reviewed by another engineer (recommended) or merge the PR yourself, depending on your project and team's established processes --- From 2d333eb9b9fec7c5fac1a3e54d4b8c06b5ae9c8e Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Wed, 12 Nov 2025 21:12:50 -0500 Subject: [PATCH 16/22] fix: prevent data loss when test case not found in source file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a critical bug where test_cases.yaml files could be wiped out when writing PR or issue metadata back to files. The bug occurred when a test case's _source_file metadata pointed to a file that no longer contained that test case (e.g., after the test case was moved to another file). The code would load the file, fail to find the matching test case, but still save the file anyway - overwriting it with whatever was loaded (often just `test_cases: []`). Changes: - Add test_case_found flag to track if matching test case was located - Only save file if a matching test case was actually found and updated - Add warning logs when test case not found to aid debugging This prevents accidental data loss in criteria_needs_review.yaml and other test case files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/synchronize/pull_requests.py | 17 +++++++++++++---- .../synchronize/tracking_issues.py | 17 +++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/github_ops_manager/synchronize/pull_requests.py b/github_ops_manager/synchronize/pull_requests.py index cb00953..a141038 100644 --- a/github_ops_manager/synchronize/pull_requests.py +++ b/github_ops_manager/synchronize/pull_requests.py @@ -561,16 +561,25 @@ async def create_catalog_pull_requests( data = load_test_cases_yaml(source_file) if data and "test_cases" in data: # Find the test case and update it + test_case_found = False for tc in data["test_cases"]: if tc.get("generated_script_path") == script_path: update_test_case_with_pr_metadata(tc, new_pr, catalog_repo_url) + test_case_found = True break - # Save back to file - if save_test_cases_yaml(source_file, data): - logger.info("Successfully wrote PR metadata back to test case file", source_file=str(source_file)) + # Save back to file ONLY if we found and updated a test case + if test_case_found: + if save_test_cases_yaml(source_file, data): + logger.info("Successfully wrote PR metadata back to test case file", source_file=str(source_file)) + else: + logger.error("Failed to save test case file", source_file=str(source_file)) else: - logger.error("Failed to save test case file", source_file=str(source_file)) + logger.warning( + "Test case not found in source file, skipping save to prevent data loss", + source_file=str(source_file), + script_path=script_path, + ) logger.info( "Completed catalog PR creation", diff --git a/github_ops_manager/synchronize/tracking_issues.py b/github_ops_manager/synchronize/tracking_issues.py index 78e67ee..c7b2587 100644 --- a/github_ops_manager/synchronize/tracking_issues.py +++ b/github_ops_manager/synchronize/tracking_issues.py @@ -172,16 +172,25 @@ async def create_tracking_issue_for_catalog_pr( if data and "test_cases" in data: # Find the test case and update it # Match by title since that's unique and reliable + test_case_found = False for tc in data["test_cases"]: if tc.get("title") == test_case_title: update_test_case_with_issue_metadata(tc, issue.number, issue.html_url) + test_case_found = True break - # Save back to file - if save_test_cases_yaml(source_file, data): - logger.info("Successfully wrote project issue metadata back to test case file", source_file=str(source_file)) + # Save back to file ONLY if we found and updated a test case + if test_case_found: + if save_test_cases_yaml(source_file, data): + logger.info("Successfully wrote project issue metadata back to test case file", source_file=str(source_file)) + else: + logger.error("Failed to save test case file", source_file=str(source_file)) else: - logger.error("Failed to save test case file", source_file=str(source_file)) + logger.warning( + "Test case not found in source file, skipping save to prevent data loss", + source_file=str(source_file), + test_case_title=test_case_title, + ) else: logger.warning("Could not load test cases from source file", source_file=str(source_file)) else: From fb37bf49900c329d03b1d44b24a6f4a250c7ca8c Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 13 Nov 2025 18:01:32 -0500 Subject: [PATCH 17/22] fix: add defensive check to prevent wiping files with empty test_cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an additional safety check in save_test_cases_yaml() that refuses to save a file if: 1. The file exists and has test cases 2. The new data would replace it with test_cases: [] This provides defense-in-depth against data loss, complementing the test_case_found checks added in commit 2d333eb. If this check triggers, it logs an error with the existing and new test case counts to aid debugging. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../processing/test_cases_processor.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py index 2e5ec50..d1d3bff 100644 --- a/github_ops_manager/processing/test_cases_processor.py +++ b/github_ops_manager/processing/test_cases_processor.py @@ -208,6 +208,24 @@ def save_test_cases_yaml(filepath: Path, data: dict[str, Any]) -> bool: True if save succeeded, False otherwise """ try: + # Safety check: prevent wiping out files with data by replacing with empty test_cases + if filepath.exists(): + # Load existing file to check if it has content + existing_data = load_test_cases_yaml(filepath) + if existing_data and "test_cases" in existing_data: + existing_test_cases = existing_data.get("test_cases", []) + new_test_cases = data.get("test_cases", []) + + # If existing file has test cases but new data has none, refuse to save + if len(existing_test_cases) > 0 and len(new_test_cases) == 0: + logger.error( + "Refusing to save: would wipe out existing test cases", + filepath=str(filepath), + existing_count=len(existing_test_cases), + new_count=len(new_test_cases), + ) + return False + with open(filepath, "w", encoding="utf-8") as f: yaml.dump(data, f) From 85b03225b29cfd096695d9177c50c40275b8687d Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 13 Nov 2025 18:06:16 -0500 Subject: [PATCH 18/22] fix: use atomic file writes to prevent data loss from truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL FIX: The previous implementation had a fatal flaw where open(filepath, "w") immediately truncates the file to 0 bytes BEFORE yaml.dump() is called. If yaml.dump() failed or data was invalid, the file would be left completely empty. This commit implements atomic file writing: 1. Write to a temporary file in the same directory 2. Only if yaml.dump() succeeds, atomically rename temp to target 3. os.replace() is atomic on POSIX - either complete or not at all 4. If anything fails, original file remains untouched This prevents the exact scenario where files are being blanked out (not even containing test_cases: []) as reported in CI runs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../processing/test_cases_processor.py | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py index d1d3bff..cdb9c7a 100644 --- a/github_ops_manager/processing/test_cases_processor.py +++ b/github_ops_manager/processing/test_cases_processor.py @@ -226,11 +226,29 @@ def save_test_cases_yaml(filepath: Path, data: dict[str, Any]) -> bool: ) return False - with open(filepath, "w", encoding="utf-8") as f: - yaml.dump(data, f) - - logger.info("Saved test cases YAML", filepath=str(filepath)) - return True + # CRITICAL: Use atomic write to prevent data loss if yaml.dump() fails + # Write to temporary file first, then rename atomically + import os + import tempfile + + temp_fd, temp_path = tempfile.mkstemp(dir=filepath.parent, prefix=f".{filepath.name}.", suffix=".tmp") + try: + with os.fdopen(temp_fd, "w", encoding="utf-8") as f: + yaml.dump(data, f) + + # Atomic rename - if this fails, original file is untouched + os.replace(temp_path, filepath) + + logger.info("Saved test cases YAML", filepath=str(filepath)) + return True + + except Exception as e: + # Clean up temp file if something went wrong + try: + os.unlink(temp_path) + except Exception: + pass + raise e except Exception as e: logger.error("Failed to save test cases YAML", filepath=str(filepath), error=str(e)) From 436a5ca651cf274adbd5304716cc7b7ad3fd166a Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 13 Nov 2025 18:26:02 -0500 Subject: [PATCH 19/22] fix: handle large files (>1MB) in fetch-files command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL FIX: The fetch-files command was creating 0-byte files when fetching files larger than 1MB because GitHub API's get_content endpoint returns content=None for large files and provides a download_url instead. The criteria_needs_review.yaml file is 2.7MB, which exceeds the 1MB limit, causing it to be fetched as empty and then overwriting the good file during mv operations in CI. Changes: - Check if response.parsed_data.content is None or empty - If so, use the download_url to fetch raw file content via httpx - Raise error if no download_url is available - Add logging for large file downloads This fixes the root cause of files being blanked out in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/github/adapter.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/github_ops_manager/github/adapter.py b/github_ops_manager/github/adapter.py index 2cf021c..f450819 100644 --- a/github_ops_manager/github/adapter.py +++ b/github_ops_manager/github/adapter.py @@ -473,13 +473,36 @@ async def list_files_in_pull_request(self, pull_number: int) -> list[Any]: return response.parsed_data async def get_file_content_from_pull_request(self, file_path: str, branch: str) -> str: - """Get the content of a file from a specific branch (typically the PR's head branch).""" + """Get the content of a file from a specific branch (typically the PR's head branch). + + Handles both small files (inline base64 content) and large files (> 1MB, via download_url). + """ response = await self.client.rest.repos.async_get_content( owner=self.owner, repo=self.repo_name, path=file_path, ref=branch, ) + + # For large files (> 1MB), GitHub API returns content as None and provides download_url + if response.parsed_data.content is None or response.parsed_data.content == "": + download_url = getattr(response.parsed_data, "download_url", None) + if download_url: + logger.info( + "File too large for inline content, using download_url", + file_path=file_path, + download_url=download_url, + ) + # Use httpx to download the raw file content + import httpx + + async with httpx.AsyncClient() as client: + download_response = await client.get(download_url) + download_response.raise_for_status() + return download_response.text + else: + raise ValueError(f"File content is empty and no download_url provided for {file_path}") + return base64.b64decode(response.parsed_data.content).decode("utf-8") # Release/Tag Operations From 2f2f022fafa928ef6af1673f979c9ac3646082f6 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 13 Nov 2025 18:32:48 -0500 Subject: [PATCH 20/22] Revert "fix: add defensive check to prevent wiping files with empty test_cases" This reverts commit fb37bf49900c329d03b1d44b24a6f4a250c7ca8c. --- .../processing/test_cases_processor.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/github_ops_manager/processing/test_cases_processor.py b/github_ops_manager/processing/test_cases_processor.py index cdb9c7a..e123e44 100644 --- a/github_ops_manager/processing/test_cases_processor.py +++ b/github_ops_manager/processing/test_cases_processor.py @@ -208,24 +208,6 @@ def save_test_cases_yaml(filepath: Path, data: dict[str, Any]) -> bool: True if save succeeded, False otherwise """ try: - # Safety check: prevent wiping out files with data by replacing with empty test_cases - if filepath.exists(): - # Load existing file to check if it has content - existing_data = load_test_cases_yaml(filepath) - if existing_data and "test_cases" in existing_data: - existing_test_cases = existing_data.get("test_cases", []) - new_test_cases = data.get("test_cases", []) - - # If existing file has test cases but new data has none, refuse to save - if len(existing_test_cases) > 0 and len(new_test_cases) == 0: - logger.error( - "Refusing to save: would wipe out existing test cases", - filepath=str(filepath), - existing_count=len(existing_test_cases), - new_count=len(new_test_cases), - ) - return False - # CRITICAL: Use atomic write to prevent data loss if yaml.dump() fails # Write to temporary file first, then rename atomically import os From 3332dc64a95ab6defd3c60e9cc4c60238466e312 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 13 Nov 2025 20:29:39 -0500 Subject: [PATCH 21/22] feat: add test requirement section to catalog tracking issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhanced tracking issues created for catalog PRs to include a "Test Requirement" section with key metadata extracted from the test case definition: - purpose - commands (list only, no outputs) - pass_criteria - sample_parameters - parameters_to_parsed_data_mapping This provides reviewers with immediate visibility into what the test requirement is designed to do without needing to navigate to the catalog PR or test case files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../synchronize/tracking_issues.py | 19 +++++++++++++++++++ .../templates/tracking_issue.j2 | 15 +++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/github_ops_manager/synchronize/tracking_issues.py b/github_ops_manager/synchronize/tracking_issues.py index c7b2587..d448a79 100644 --- a/github_ops_manager/synchronize/tracking_issues.py +++ b/github_ops_manager/synchronize/tracking_issues.py @@ -122,6 +122,24 @@ async def create_tracking_issue_for_catalog_pr( title = f"Review Catalog PR and Learn Parameters: {test_case_title}" + # Extract test requirement data from test case + # Commands list should only contain command strings, not full command objects + commands_list = [] + if "commands" in test_case: + for cmd in test_case["commands"]: + if isinstance(cmd, dict): + commands_list.append(cmd.get("command", "")) + else: + commands_list.append(str(cmd)) + + 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", ""), + } + # Load and render the tracking issue template template = load_tracking_issue_template() body = template.render( @@ -133,6 +151,7 @@ async def create_tracking_issue_for_catalog_pr( 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, ) logger.info( diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index 8b7d031..9b9da19 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -5,6 +5,21 @@ **Catalog Branch**: `{{ catalog_branch }}` **Operating System**: {{ os_name }} +## Test Requirement + +```yaml +purpose: {{ test_requirement.purpose }} +commands: +{% for cmd in test_requirement.commands -%} + - {{ cmd }} +{% endfor -%} +pass_criteria: {{ test_requirement.pass_criteria }} +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) }} +``` + ### Tasks - [ ] Create a new branch in this project repository to track your test case parameter additions. Suggested branch name: From f72d9afd5c67667eae0a34edcbb211c30a623467 Mon Sep 17 00:00:00 2001 From: Christopher Hart Date: Thu, 13 Nov 2025 21:09:36 -0500 Subject: [PATCH 22/22] fix: improve YAML formatting in test requirement section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use literal block scalars (|) for purpose, pass_criteria, and parameters_to_parsed_data_mapping fields to properly handle multi-line strings and special characters - Quote command strings to handle pipes and other special characters - Ensures generated YAML in tracking issues is always valid 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- github_ops_manager/templates/tracking_issue.j2 | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/github_ops_manager/templates/tracking_issue.j2 b/github_ops_manager/templates/tracking_issue.j2 index 9b9da19..49fb3d2 100644 --- a/github_ops_manager/templates/tracking_issue.j2 +++ b/github_ops_manager/templates/tracking_issue.j2 @@ -8,12 +8,14 @@ ## Test Requirement ```yaml -purpose: {{ test_requirement.purpose }} +purpose: | +{{ test_requirement.purpose | indent(2, first=True) }} commands: {% for cmd in test_requirement.commands -%} - - {{ cmd }} + - "{{ cmd }}" {% endfor -%} -pass_criteria: {{ test_requirement.pass_criteria }} +pass_criteria: | +{{ test_requirement.pass_criteria | indent(2, first=True) }} sample_parameters: | {{ test_requirement.sample_parameters | indent(2, first=True) }} parameters_to_parsed_data_mapping: |