From e624b41cee3524fd9acb16bd2031c7010fe0bcfc Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Thu, 8 Jan 2026 15:14:56 -0500 Subject: [PATCH 01/11] feat(ci): Stale Component issue creation GHA Signed-off-by: Giulio Frasca --- .github/scripts/create_stale_issues/README.md | 26 +++ .../create_stale_issues.py | 151 ++++++++++++++++++ .../create_stale_issues/issue_body.md.j2 | 26 +++ .github/workflows/stale-component-check.yml | 48 ++++++ 4 files changed, 251 insertions(+) create mode 100644 .github/scripts/create_stale_issues/README.md create mode 100755 .github/scripts/create_stale_issues/create_stale_issues.py create mode 100644 .github/scripts/create_stale_issues/issue_body.md.j2 create mode 100644 .github/workflows/stale-component-check.yml diff --git a/.github/scripts/create_stale_issues/README.md b/.github/scripts/create_stale_issues/README.md new file mode 100644 index 000000000..eb0241d07 --- /dev/null +++ b/.github/scripts/create_stale_issues/README.md @@ -0,0 +1,26 @@ +# Create Stale Issues + +Create GitHub issues for stale components that need re-verification. + +## What it Does + +1. Uses `scripts/check_component_freshness` to find components needing attention: + - 🟡 Stale: 270-360 days since last verification + - 🔴 Flagged for Deletion: >360 days since last verification +2. Reads `OWNERS` file to get maintainers +3. Creates GitHub issues with: + - Title: `Component X needs verification` + - Label: `stale-component` + - Assignees: component owners + - Body: mentions owners, includes update instructions + +## Usage + +```bash +# Dry run (see what would be created) +uv run .github/scripts/create_stale_issues/create_stale_issues.py --repo owner/repo --dry-run + +# Create issues +GITHUB_TOKEN=ghp_xxx uv run .github/scripts/create_stale_issues/create_stale_issues.py --repo owner/repo +``` + diff --git a/.github/scripts/create_stale_issues/create_stale_issues.py b/.github/scripts/create_stale_issues/create_stale_issues.py new file mode 100755 index 000000000..de342ee13 --- /dev/null +++ b/.github/scripts/create_stale_issues/create_stale_issues.py @@ -0,0 +1,151 @@ +"""Create GitHub issues for components approaching or past their verification deadline.""" + +import argparse +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +import requests +import yaml +from jinja2 import Environment, FileSystemLoader + +# Add repo root to path so we can import from scripts/ +REPO_ROOT = Path(__file__).parent.parent.parent.parent +sys.path.insert(0, str(REPO_ROOT)) +from scripts.check_component_freshness.check_component_freshness import scan_repo + +LABEL = "stale-component" +TEMPLATE_DIR = Path(__file__).parent + +ISSUE_BODY_TEMPLATE = "issue_body.md.j2" + +# Maximum issues to check when looking for duplicates (GitHub API max is 100) +MAX_ISSUES_PER_PAGE = 100 + + +def get_issue_title(component_name: str) -> str: + """Generate the standard issue title for a stale component.""" + return f"Component `{component_name}` needs verification" + + +def get_owners(component_path: Path) -> list[str]: + """Read OWNERS file for a component.""" + owners_file = component_path / "OWNERS" + if not owners_file.exists(): + return [] + try: + owners = yaml.safe_load(owners_file.read_text()) + return owners.get("approvers", []) if owners else [] + except Exception: + return [] + + +def create_issue_body(component: dict, repo_path: Path) -> str: + """Generate the issue body with instructions using Jinja2 template.""" + env = Environment(loader=FileSystemLoader(TEMPLATE_DIR)) + template = env.get_template(ISSUE_BODY_TEMPLATE) + + owners = get_owners(repo_path / component["path"]) + owners_mention = ", ".join(f"@{o}" for o in owners) if owners else "No owners found" + + return template.render( + name=component["name"], + path=component["path"], + last_verified=component["last_verified"], + age_days=component["age_days"], + owners_mention=owners_mention, + today=datetime.now(timezone.utc).strftime("%Y-%m-%d"), + ) + + +def issue_exists(repo: str, component_name: str, token: str | None) -> bool: + """Check if an open issue already exists for this component.""" + expected_title = get_issue_title(component_name) + headers = {"Accept": "application/vnd.github.v3+json"} + if token: + headers["Authorization"] = f"token {token}" + try: + resp = requests.get( + f"https://api.github.com/repos/{repo}/issues", + headers=headers, + params={"state": "open", "labels": LABEL, "per_page": MAX_ISSUES_PER_PAGE}, + timeout=30, + ) + resp.raise_for_status() + return any(issue["title"] == expected_title for issue in resp.json()) + except Exception as e: + print(f"❌ Failed to check for existing issue: {e}", file=sys.stderr) + return False + + +def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, dry_run: bool) -> bool: + """Create a GitHub issue for the stale component.""" + title = get_issue_title(component["name"]) + + if dry_run: + owners = get_owners(repo_path / component["path"]) + print(f"[DRY RUN] Would create: {title}") + print(f" Owners: {owners}") + return True + + headers = {"Accept": "application/vnd.github.v3+json"} + if token: + headers["Authorization"] = f"token {token}" + body = create_issue_body(component, repo_path) + + try: + resp = requests.post( + f"https://api.github.com/repos/{repo}/issues", + headers=headers, + json={"title": title, "body": body, "labels": [LABEL]}, + timeout=30, + ) + resp.raise_for_status() + print(f"✅ Created: {resp.json().get('html_url')}") + return True + except requests.exceptions.RequestException as e: + print(f"❌ Failed for {component['name']}: {e}", file=sys.stderr) + return False + + +def create_issues_for_stale_components(repo: str, token: str | None, dry_run: bool) -> int: + """Create GitHub issues for stale components.""" + repo_path = REPO_ROOT + results = scan_repo(repo_path) + stale = results.get("stale", []) + + if not stale: + print("No stale components found.") + return 0 + + print(f"Found {len(stale)} stale component(s)\n") + + created, skipped = 0, 0 + for component in stale: + if issue_exists(repo, component["name"], token): + print(f"⏭️ Skipping {component['name']}: issue already exists") + skipped += 1 + continue + if create_issue(repo, component, repo_path, token, dry_run): + created += 1 + + print(f"\nSummary: {created} created, {skipped} skipped") + + +def main(): + parser = argparse.ArgumentParser(description="Create GitHub issues for stale components") + parser.add_argument("--repo", required=True, help="GitHub repo (e.g., owner/repo)") + parser.add_argument("--token", help="GitHub token (or set GITHUB_TOKEN env var)") + parser.add_argument("--dry-run", action="store_true", help="Print without creating") + args = parser.parse_args() + + token = args.token or os.environ.get("GITHUB_TOKEN") + if not token: + print("Warning: No GitHub token provided. API requests will be subject to rate limiting.", file=sys.stderr) + print("Use --token or set GITHUB_TOKEN environment variable for authenticated requests.", file=sys.stderr) + + create_issues_for_stale_components(args.repo, token, args.dry_run) + +if __name__ == "__main__": + main() diff --git a/.github/scripts/create_stale_issues/issue_body.md.j2 b/.github/scripts/create_stale_issues/issue_body.md.j2 new file mode 100644 index 000000000..be6111821 --- /dev/null +++ b/.github/scripts/create_stale_issues/issue_body.md.j2 @@ -0,0 +1,26 @@ +## Component Verification Required + +The component **{{ name }}** has not been verified in over 365 days. + +| Field | Value | +|-------------------------|---------------------| +| Path | `{{ path }}` | +| Last Verified | {{ last_verified }} | +| Days Since Verification | {{ age_days }} | + +### Maintainers + +{{ owners_mention }} + +### How to Resolve + +1. Verify the component still works with the current KFP version +2. Run any associated tests +3. Update `lastVerified` in `{{ path }}/metadata.yaml`: + +```yaml +lastVerified: {{ today }} +``` + +4. Submit a PR with the updated metadata + diff --git a/.github/workflows/stale-component-check.yml b/.github/workflows/stale-component-check.yml new file mode 100644 index 000000000..5ab0e9f99 --- /dev/null +++ b/.github/workflows/stale-component-check.yml @@ -0,0 +1,48 @@ +name: Stale Component Check + +on: + schedule: + # Run weekly on Monday at 9:00 AM UTC + - cron: '0 9 * * 1' + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (do not create issues)' + required: false + default: false + type: boolean + +jobs: + check-stale-components: + name: Check for stale components + runs-on: ubuntu-24.04 + permissions: + issues: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python CI + uses: ./.github/actions/setup-python-ci + with: + python-version: 3.11 + + - name: Create issues for stale components + if: github.event.inputs.dry_run != 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv run .github/scripts/create_stale_issues/create_stale_issues.py \ + --repo ${{ github.repository }} + + - name: Dry run (show what would be created) + if: github.event.inputs.dry_run == 'true' + run: | + uv run .github/scripts/create_stale_issues/create_stale_issues.py \ + --repo ${{ github.repository }} \ + --dry-run + From ce63d00a57f0db2d953145be5ccbfaeb532c08c4 Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Tue, 27 Jan 2026 21:02:16 -0500 Subject: [PATCH 02/11] chore: Assign owners to created stale-component Issues Signed-off-by: Giulio Frasca --- .../scripts/create_stale_issues/create_stale_issues.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/scripts/create_stale_issues/create_stale_issues.py b/.github/scripts/create_stale_issues/create_stale_issues.py index de342ee13..9d62b0043 100755 --- a/.github/scripts/create_stale_issues/create_stale_issues.py +++ b/.github/scripts/create_stale_issues/create_stale_issues.py @@ -17,11 +17,12 @@ LABEL = "stale-component" TEMPLATE_DIR = Path(__file__).parent - ISSUE_BODY_TEMPLATE = "issue_body.md.j2" # Maximum issues to check when looking for duplicates (GitHub API max is 100) MAX_ISSUES_PER_PAGE = 100 +# GitHub API limits assignees to 10 per issue +MAX_ASSIGNEES = 10 def get_issue_title(component_name: str) -> str: @@ -82,11 +83,12 @@ def issue_exists(repo: str, component_name: str, token: str | None) -> bool: def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, dry_run: bool) -> bool: """Create a GitHub issue for the stale component.""" title = get_issue_title(component["name"]) + owners = get_owners(repo_path / component["path"]) + assignees = owners[:MAX_ASSIGNEES] if dry_run: - owners = get_owners(repo_path / component["path"]) print(f"[DRY RUN] Would create: {title}") - print(f" Owners: {owners}") + print(f" Assignees: {assignees}") return True headers = {"Accept": "application/vnd.github.v3+json"} @@ -98,7 +100,7 @@ def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, resp = requests.post( f"https://api.github.com/repos/{repo}/issues", headers=headers, - json={"title": title, "body": body, "labels": [LABEL]}, + json={"title": title, "body": body, "labels": [LABEL], "assignees": assignees}, timeout=30, ) resp.raise_for_status() From 0299356c4d349a1213bbd8dd8d25795ab1e896cb Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Tue, 27 Jan 2026 21:04:26 -0500 Subject: [PATCH 03/11] chore: Create stale-component GH Issues at first staleness threshold - Stale components are ones that cross first, earlier threshold - Original threshold is for components flagged for deletion Signed-off-by: Giulio Frasca --- .github/scripts/create_stale_issues/README.md | 2 +- .../create_stale_issues.py | 20 +++++++++++-------- .../create_stale_issues/issue_body.md.j2 | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/scripts/create_stale_issues/README.md b/.github/scripts/create_stale_issues/README.md index eb0241d07..787dd709b 100644 --- a/.github/scripts/create_stale_issues/README.md +++ b/.github/scripts/create_stale_issues/README.md @@ -1,6 +1,6 @@ # Create Stale Issues -Create GitHub issues for stale components that need re-verification. +Create GitHub issues for components approaching or past their verification deadline. ## What it Does diff --git a/.github/scripts/create_stale_issues/create_stale_issues.py b/.github/scripts/create_stale_issues/create_stale_issues.py index 9d62b0043..d72acea73 100755 --- a/.github/scripts/create_stale_issues/create_stale_issues.py +++ b/.github/scripts/create_stale_issues/create_stale_issues.py @@ -81,7 +81,7 @@ def issue_exists(repo: str, component_name: str, token: str | None) -> bool: def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, dry_run: bool) -> bool: - """Create a GitHub issue for the stale component.""" + """Create a GitHub issue for a component needing verification.""" title = get_issue_title(component["name"]) owners = get_owners(repo_path / component["path"]) assignees = owners[:MAX_ASSIGNEES] @@ -112,19 +112,23 @@ def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, def create_issues_for_stale_components(repo: str, token: str | None, dry_run: bool) -> int: - """Create GitHub issues for stale components.""" + """Create GitHub issues for components in warning or stale status.""" repo_path = REPO_ROOT results = scan_repo(repo_path) - stale = results.get("stale", []) + # Include both warning (270-360 days) and stale (>360 days) components + components_needing_attention = results.get("warning", []) + results.get("stale", []) - if not stale: - print("No stale components found.") + if not components_needing_attention: + print("No components need verification.") return 0 - print(f"Found {len(stale)} stale component(s)\n") + warning_count = len(results.get("warning", [])) + stale_count = len(results.get("stale", [])) + print(f"Found {len(components_needing_attention)} component(s) needing verification") + print(f" 🟡 Warning: {warning_count}, 🔴 Stale: {stale_count}\n") created, skipped = 0, 0 - for component in stale: + for component in components_needing_attention: if issue_exists(repo, component["name"], token): print(f"⏭️ Skipping {component['name']}: issue already exists") skipped += 1 @@ -136,7 +140,7 @@ def create_issues_for_stale_components(repo: str, token: str | None, dry_run: bo def main(): - parser = argparse.ArgumentParser(description="Create GitHub issues for stale components") + parser = argparse.ArgumentParser(description="Create GitHub issues for components needing verification") parser.add_argument("--repo", required=True, help="GitHub repo (e.g., owner/repo)") parser.add_argument("--token", help="GitHub token (or set GITHUB_TOKEN env var)") parser.add_argument("--dry-run", action="store_true", help="Print without creating") diff --git a/.github/scripts/create_stale_issues/issue_body.md.j2 b/.github/scripts/create_stale_issues/issue_body.md.j2 index be6111821..7b4d8cca6 100644 --- a/.github/scripts/create_stale_issues/issue_body.md.j2 +++ b/.github/scripts/create_stale_issues/issue_body.md.j2 @@ -1,6 +1,6 @@ ## Component Verification Required -The component **{{ name }}** has not been verified in over 365 days. +The component **{{ name }}** has not been verified in {{ age_days }} days and needs attention. | Field | Value | |-------------------------|---------------------| From ddeee94395efaefd14966f55be934d9c86b76bc6 Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Wed, 28 Jan 2026 20:24:07 -0500 Subject: [PATCH 04/11] chore: Staleness check formatting cleanup Signed-off-by: Giulio Frasca --- .github/scripts/create_stale_issues/README.md | 1 - .../create_stale_issues/create_stale_issues.py | 14 ++++++++------ .github/workflows/stale-component-check.yml | 4 ++-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/scripts/create_stale_issues/README.md b/.github/scripts/create_stale_issues/README.md index 787dd709b..a5607602c 100644 --- a/.github/scripts/create_stale_issues/README.md +++ b/.github/scripts/create_stale_issues/README.md @@ -23,4 +23,3 @@ uv run .github/scripts/create_stale_issues/create_stale_issues.py --repo owner/r # Create issues GITHUB_TOKEN=ghp_xxx uv run .github/scripts/create_stale_issues/create_stale_issues.py --repo owner/repo ``` - diff --git a/.github/scripts/create_stale_issues/create_stale_issues.py b/.github/scripts/create_stale_issues/create_stale_issues.py index d72acea73..55c2860db 100755 --- a/.github/scripts/create_stale_issues/create_stale_issues.py +++ b/.github/scripts/create_stale_issues/create_stale_issues.py @@ -13,7 +13,7 @@ # Add repo root to path so we can import from scripts/ REPO_ROOT = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(REPO_ROOT)) -from scripts.check_component_freshness.check_component_freshness import scan_repo +from scripts.check_component_freshness.check_component_freshness import scan_repo # noqa: E402 LABEL = "stale-component" TEMPLATE_DIR = Path(__file__).parent @@ -76,7 +76,7 @@ def issue_exists(repo: str, component_name: str, token: str | None) -> bool: resp.raise_for_status() return any(issue["title"] == expected_title for issue in resp.json()) except Exception as e: - print(f"❌ Failed to check for existing issue: {e}", file=sys.stderr) + print(f"Failed to check for existing issue: {e}", file=sys.stderr) return False @@ -104,10 +104,10 @@ def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, timeout=30, ) resp.raise_for_status() - print(f"✅ Created: {resp.json().get('html_url')}") + print(f"Created: {resp.json().get('html_url')}") return True except requests.exceptions.RequestException as e: - print(f"❌ Failed for {component['name']}: {e}", file=sys.stderr) + print(f"Failed to create issue for {component['name']}: {e}", file=sys.stderr) return False @@ -125,12 +125,12 @@ def create_issues_for_stale_components(repo: str, token: str | None, dry_run: bo warning_count = len(results.get("warning", [])) stale_count = len(results.get("stale", [])) print(f"Found {len(components_needing_attention)} component(s) needing verification") - print(f" 🟡 Warning: {warning_count}, 🔴 Stale: {stale_count}\n") + print(f" Warning: {warning_count}, Stale: {stale_count}\n") created, skipped = 0, 0 for component in components_needing_attention: if issue_exists(repo, component["name"], token): - print(f"⏭️ Skipping {component['name']}: issue already exists") + print(f"Skipping {component['name']}: issue already exists") skipped += 1 continue if create_issue(repo, component, repo_path, token, dry_run): @@ -140,6 +140,7 @@ def create_issues_for_stale_components(repo: str, token: str | None, dry_run: bo def main(): + """Create GitHub issues for components needing staleness or deletion verification.""" parser = argparse.ArgumentParser(description="Create GitHub issues for components needing verification") parser.add_argument("--repo", required=True, help="GitHub repo (e.g., owner/repo)") parser.add_argument("--token", help="GitHub token (or set GITHUB_TOKEN env var)") @@ -153,5 +154,6 @@ def main(): create_issues_for_stale_components(args.repo, token, args.dry_run) + if __name__ == "__main__": main() diff --git a/.github/workflows/stale-component-check.yml b/.github/workflows/stale-component-check.yml index 5ab0e9f99..b205ce2e7 100644 --- a/.github/workflows/stale-component-check.yml +++ b/.github/workflows/stale-component-check.yml @@ -1,3 +1,4 @@ +--- name: Stale Component Check on: @@ -25,7 +26,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - + - name: Setup Python CI uses: ./.github/actions/setup-python-ci with: @@ -45,4 +46,3 @@ jobs: uv run .github/scripts/create_stale_issues/create_stale_issues.py \ --repo ${{ github.repository }} \ --dry-run - From 65037103afd7505f14e067b27c0e9328f022b09a Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Thu, 29 Jan 2026 02:41:40 -0500 Subject: [PATCH 05/11] feat(ci): Auto-create Stale Component Removal PRs - Add functionality to the stalness check ci script to create component removal PRs if flagged as fully stale Signed-off-by: Giulio Frasca --- .github/scripts/create_stale_issues/README.md | 25 -- .../create_stale_issues.py | 159 --------- .../scripts/stale_component_handler/README.md | 37 ++ .../issue_body.md.j2 | 4 +- .../removal_pr_body.md.j2 | 29 ++ .../stale_component_handler.py | 321 ++++++++++++++++++ .github/workflows/stale-component-check.yml | 22 +- 7 files changed, 405 insertions(+), 192 deletions(-) delete mode 100644 .github/scripts/create_stale_issues/README.md delete mode 100755 .github/scripts/create_stale_issues/create_stale_issues.py create mode 100644 .github/scripts/stale_component_handler/README.md rename .github/scripts/{create_stale_issues => stale_component_handler}/issue_body.md.j2 (79%) create mode 100644 .github/scripts/stale_component_handler/removal_pr_body.md.j2 create mode 100755 .github/scripts/stale_component_handler/stale_component_handler.py diff --git a/.github/scripts/create_stale_issues/README.md b/.github/scripts/create_stale_issues/README.md deleted file mode 100644 index a5607602c..000000000 --- a/.github/scripts/create_stale_issues/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Create Stale Issues - -Create GitHub issues for components approaching or past their verification deadline. - -## What it Does - -1. Uses `scripts/check_component_freshness` to find components needing attention: - - 🟡 Stale: 270-360 days since last verification - - 🔴 Flagged for Deletion: >360 days since last verification -2. Reads `OWNERS` file to get maintainers -3. Creates GitHub issues with: - - Title: `Component X needs verification` - - Label: `stale-component` - - Assignees: component owners - - Body: mentions owners, includes update instructions - -## Usage - -```bash -# Dry run (see what would be created) -uv run .github/scripts/create_stale_issues/create_stale_issues.py --repo owner/repo --dry-run - -# Create issues -GITHUB_TOKEN=ghp_xxx uv run .github/scripts/create_stale_issues/create_stale_issues.py --repo owner/repo -``` diff --git a/.github/scripts/create_stale_issues/create_stale_issues.py b/.github/scripts/create_stale_issues/create_stale_issues.py deleted file mode 100755 index 55c2860db..000000000 --- a/.github/scripts/create_stale_issues/create_stale_issues.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Create GitHub issues for components approaching or past their verification deadline.""" - -import argparse -import os -import sys -from datetime import datetime, timezone -from pathlib import Path - -import requests -import yaml -from jinja2 import Environment, FileSystemLoader - -# Add repo root to path so we can import from scripts/ -REPO_ROOT = Path(__file__).parent.parent.parent.parent -sys.path.insert(0, str(REPO_ROOT)) -from scripts.check_component_freshness.check_component_freshness import scan_repo # noqa: E402 - -LABEL = "stale-component" -TEMPLATE_DIR = Path(__file__).parent -ISSUE_BODY_TEMPLATE = "issue_body.md.j2" - -# Maximum issues to check when looking for duplicates (GitHub API max is 100) -MAX_ISSUES_PER_PAGE = 100 -# GitHub API limits assignees to 10 per issue -MAX_ASSIGNEES = 10 - - -def get_issue_title(component_name: str) -> str: - """Generate the standard issue title for a stale component.""" - return f"Component `{component_name}` needs verification" - - -def get_owners(component_path: Path) -> list[str]: - """Read OWNERS file for a component.""" - owners_file = component_path / "OWNERS" - if not owners_file.exists(): - return [] - try: - owners = yaml.safe_load(owners_file.read_text()) - return owners.get("approvers", []) if owners else [] - except Exception: - return [] - - -def create_issue_body(component: dict, repo_path: Path) -> str: - """Generate the issue body with instructions using Jinja2 template.""" - env = Environment(loader=FileSystemLoader(TEMPLATE_DIR)) - template = env.get_template(ISSUE_BODY_TEMPLATE) - - owners = get_owners(repo_path / component["path"]) - owners_mention = ", ".join(f"@{o}" for o in owners) if owners else "No owners found" - - return template.render( - name=component["name"], - path=component["path"], - last_verified=component["last_verified"], - age_days=component["age_days"], - owners_mention=owners_mention, - today=datetime.now(timezone.utc).strftime("%Y-%m-%d"), - ) - - -def issue_exists(repo: str, component_name: str, token: str | None) -> bool: - """Check if an open issue already exists for this component.""" - expected_title = get_issue_title(component_name) - headers = {"Accept": "application/vnd.github.v3+json"} - if token: - headers["Authorization"] = f"token {token}" - try: - resp = requests.get( - f"https://api.github.com/repos/{repo}/issues", - headers=headers, - params={"state": "open", "labels": LABEL, "per_page": MAX_ISSUES_PER_PAGE}, - timeout=30, - ) - resp.raise_for_status() - return any(issue["title"] == expected_title for issue in resp.json()) - except Exception as e: - print(f"Failed to check for existing issue: {e}", file=sys.stderr) - return False - - -def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, dry_run: bool) -> bool: - """Create a GitHub issue for a component needing verification.""" - title = get_issue_title(component["name"]) - owners = get_owners(repo_path / component["path"]) - assignees = owners[:MAX_ASSIGNEES] - - if dry_run: - print(f"[DRY RUN] Would create: {title}") - print(f" Assignees: {assignees}") - return True - - headers = {"Accept": "application/vnd.github.v3+json"} - if token: - headers["Authorization"] = f"token {token}" - body = create_issue_body(component, repo_path) - - try: - resp = requests.post( - f"https://api.github.com/repos/{repo}/issues", - headers=headers, - json={"title": title, "body": body, "labels": [LABEL], "assignees": assignees}, - timeout=30, - ) - resp.raise_for_status() - print(f"Created: {resp.json().get('html_url')}") - return True - except requests.exceptions.RequestException as e: - print(f"Failed to create issue for {component['name']}: {e}", file=sys.stderr) - return False - - -def create_issues_for_stale_components(repo: str, token: str | None, dry_run: bool) -> int: - """Create GitHub issues for components in warning or stale status.""" - repo_path = REPO_ROOT - results = scan_repo(repo_path) - # Include both warning (270-360 days) and stale (>360 days) components - components_needing_attention = results.get("warning", []) + results.get("stale", []) - - if not components_needing_attention: - print("No components need verification.") - return 0 - - warning_count = len(results.get("warning", [])) - stale_count = len(results.get("stale", [])) - print(f"Found {len(components_needing_attention)} component(s) needing verification") - print(f" Warning: {warning_count}, Stale: {stale_count}\n") - - created, skipped = 0, 0 - for component in components_needing_attention: - if issue_exists(repo, component["name"], token): - print(f"Skipping {component['name']}: issue already exists") - skipped += 1 - continue - if create_issue(repo, component, repo_path, token, dry_run): - created += 1 - - print(f"\nSummary: {created} created, {skipped} skipped") - - -def main(): - """Create GitHub issues for components needing staleness or deletion verification.""" - parser = argparse.ArgumentParser(description="Create GitHub issues for components needing verification") - parser.add_argument("--repo", required=True, help="GitHub repo (e.g., owner/repo)") - parser.add_argument("--token", help="GitHub token (or set GITHUB_TOKEN env var)") - parser.add_argument("--dry-run", action="store_true", help="Print without creating") - args = parser.parse_args() - - token = args.token or os.environ.get("GITHUB_TOKEN") - if not token: - print("Warning: No GitHub token provided. API requests will be subject to rate limiting.", file=sys.stderr) - print("Use --token or set GITHUB_TOKEN environment variable for authenticated requests.", file=sys.stderr) - - create_issues_for_stale_components(args.repo, token, args.dry_run) - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/stale_component_handler/README.md b/.github/scripts/stale_component_handler/README.md new file mode 100644 index 000000000..4c84a29e6 --- /dev/null +++ b/.github/scripts/stale_component_handler/README.md @@ -0,0 +1,37 @@ +# Stale Component Handler + +Handles components approaching or past their verification deadline. + +## What it Does + +1. Uses `scripts/check_component_freshness` to categorize components: + - 🟡 **Warning (270-360 days)**: Creates GitHub issues to notify owners + - 🔴 **Stale (>360 days)**: Creates PRs to remove the component + +2. For **warning** components: + - Creates issues with `stale-component` label + - Assigns component owners (up to 10) + - Includes instructions for verification + +3. For **stale** components: + - Creates a branch `remove-stale-{component-name}` + - Removes the component directory + - Opens a PR with `stale-component-removal` label + - Adds component owners as reviewers + +## Usage + +```bash +# Dry run (see what would be created) +uv run .github/scripts/stale_component_handler/stale_component_handler.py \ + --repo owner/repo --dry-run + +# Create issues and PRs +GITHUB_TOKEN=ghp_xxx uv run .github/scripts/stale_component_handler/stale_component_handler.py \ + --repo owner/repo +``` + +## Requirements + +- `gh` CLI (pre-installed on GitHub Actions runners) +- `GITHUB_TOKEN` with `issues: write`, `contents: write`, `pull-requests: write` permissions diff --git a/.github/scripts/create_stale_issues/issue_body.md.j2 b/.github/scripts/stale_component_handler/issue_body.md.j2 similarity index 79% rename from .github/scripts/create_stale_issues/issue_body.md.j2 rename to .github/scripts/stale_component_handler/issue_body.md.j2 index 7b4d8cca6..d69ff9293 100644 --- a/.github/scripts/create_stale_issues/issue_body.md.j2 +++ b/.github/scripts/stale_component_handler/issue_body.md.j2 @@ -1,6 +1,8 @@ ## Component Verification Required -The component **{{ name }}** has not been verified in {{ age_days }} days and needs attention. +The component **{{ name }}** has not been verified in {{ age_days }} days and is approaching the staleness threshold. + +**If not verified within the next {{ 360 - age_days }} days, a PR will be created to remove this component.** | Field | Value | |-------------------------|---------------------| diff --git a/.github/scripts/stale_component_handler/removal_pr_body.md.j2 b/.github/scripts/stale_component_handler/removal_pr_body.md.j2 new file mode 100644 index 000000000..a39fe34b9 --- /dev/null +++ b/.github/scripts/stale_component_handler/removal_pr_body.md.j2 @@ -0,0 +1,29 @@ +## Stale Component Removal + +This PR removes the component **{{ name }}** which has not been verified in {{ age_days }} days. + +| Field | Value | +|-------------------------|---------------------| +| Path | `{{ path }}` | +| Last Verified | {{ last_verified }} | +| Days Since Verification | {{ age_days }} | + +### Maintainers + +{{ owners_mention }} + +### Why This PR Was Created + +Components must be verified at least once per year to ensure they remain functional and compatible with current KFP versions. This component has exceeded the staleness threshold and no verification was performed. + +### Before Merging + +If this component should be kept: +1. Close this PR +2. Verify the component still works +3. Update `lastVerified` in `{{ path }}/metadata.yaml` +4. Submit a PR with the updated metadata + +If this component should be removed: +1. Review the changes +2. Approve and merge this PR diff --git a/.github/scripts/stale_component_handler/stale_component_handler.py b/.github/scripts/stale_component_handler/stale_component_handler.py new file mode 100755 index 000000000..c99bd00f8 --- /dev/null +++ b/.github/scripts/stale_component_handler/stale_component_handler.py @@ -0,0 +1,321 @@ +"""Handle stale components: create warning issues and removal PRs. + +- Warning (270-360 days): Creates GitHub issues to notify owners +- Stale (>360 days): Creates PRs to remove the component +""" + +import argparse +import os +import subprocess +import sys +import json +from datetime import datetime, timezone +from pathlib import Path + +import requests +import yaml +from jinja2 import Environment, FileSystemLoader + +# utils module sets up sys.path and re-exports from scripts/lib/discovery +REPO_ROOT = Path(__file__).parent.parent.parent.parent +sys.path.append(str(REPO_ROOT)) + +from scripts.check_component_freshness.check_component_freshness import scan_repo # noqa: E402 +from scripts.generate_readme.category_index_generator import CategoryIndexGenerator # noqa: E402 + +ISSUE_LABEL = "stale-component" +REMOVAL_LABEL = "stale-component-removal" +TEMPLATE_DIR = Path(__file__).parent +ISSUE_BODY_TEMPLATE = "issue_body.md.j2" +REMOVAL_PR_BODY_TEMPLATE = "removal_pr_body.md.j2" + +# Maximum issues to check when looking for duplicates (GitHub API max is 100) +MAX_ISSUES_PER_PAGE = 100 +# GitHub API limits assignees to 10 per issue +MAX_ASSIGNEES = 10 + + +def get_issue_title(component_name: str) -> str: + """Generate the standard issue title for a warning component.""" + return f"Component `{component_name}` needs verification" + + +def get_removal_pr_title(component_name: str) -> str: + """Generate the standard PR title for removing a stale component.""" + return f"chore: Remove stale component `{component_name}`" + + +def get_owners(component_path: Path) -> list[str]: + """Read OWNERS file for a component.""" + owners_file = component_path / "OWNERS" + if not owners_file.exists(): + return [] + try: + owners = yaml.safe_load(owners_file.read_text()) + return owners.get("approvers", []) if owners else [] + except Exception: + return [] + + +def create_issue_body(component: dict, repo_path: Path) -> str: + """Generate the issue body with instructions using Jinja2 template.""" + env = Environment(loader=FileSystemLoader(TEMPLATE_DIR)) + template = env.get_template(ISSUE_BODY_TEMPLATE) + + owners = get_owners(repo_path / component["path"]) + owners_mention = ", ".join(f"@{o}" for o in owners) if owners else "No owners found" + + return template.render( + name=component["name"], + path=component["path"], + last_verified=component["last_verified"], + age_days=component["age_days"], + owners_mention=owners_mention, + today=datetime.now(timezone.utc).strftime("%Y-%m-%d"), + ) + + +def create_removal_pr_body(component: dict, repo_path: Path) -> str: + """Generate the PR body for component removal using Jinja2 template.""" + env = Environment(loader=FileSystemLoader(TEMPLATE_DIR)) + template = env.get_template(REMOVAL_PR_BODY_TEMPLATE) + + owners = get_owners(repo_path / component["path"]) + owners_mention = ", ".join(f"@{o}" for o in owners) if owners else "No owners found" + + return template.render( + name=component["name"], + path=component["path"], + last_verified=component["last_verified"], + age_days=component["age_days"], + owners_mention=owners_mention, + ) + + +def issue_exists(repo: str, component_name: str, token: str | None) -> bool: + """Check if an open issue already exists for this component.""" + expected_title = get_issue_title(component_name) + headers = {"Accept": "application/vnd.github.v3+json"} + if token: + headers["Authorization"] = f"token {token}" + try: + resp = requests.get( + f"https://api.github.com/repos/{repo}/issues", + headers=headers, + params={"state": "open", "labels": ISSUE_LABEL, "per_page": MAX_ISSUES_PER_PAGE}, + timeout=30, + ) + resp.raise_for_status() + return any(issue["title"] == expected_title for issue in resp.json()) + except Exception as e: + print(f"Failed to check for existing issue: {e}", file=sys.stderr) + return False + + +def removal_pr_exists(repo: str, component_name: str) -> bool: + """Check if an open PR already exists for removing this component.""" + expected_title = get_removal_pr_title(component_name) + try: + result = subprocess.run( + ["gh", "pr", "list", "--repo", repo, "--state", "open", "--json", "title"], + capture_output=True, + text=True, + check=True, + ) + prs = json.loads(result.stdout) + return any(pr["title"] == expected_title for pr in prs) + except subprocess.CalledProcessError as e: + print(f"Failed to check for existing PR: {e}", file=sys.stderr) + return False + + +def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, dry_run: bool) -> bool: + """Create a GitHub issue for a component needing verification.""" + title = get_issue_title(component["name"]) + owners = get_owners(repo_path / component["path"]) + assignees = owners[:MAX_ASSIGNEES] + + if dry_run: + print(f"[DRY RUN] Would create issue: {title}") + print(f" Assignees: {assignees}") + return True + + headers = {"Accept": "application/vnd.github.v3+json"} + if token: + headers["Authorization"] = f"token {token}" + body = create_issue_body(component, repo_path) + + try: + resp = requests.post( + f"https://api.github.com/repos/{repo}/issues", + headers=headers, + json={"title": title, "body": body, "labels": [ISSUE_LABEL], "assignees": assignees}, + timeout=30, + ) + resp.raise_for_status() + print(f"Created issue: {resp.json().get('html_url')}") + return True + except requests.exceptions.RequestException as e: + print(f"Failed to create issue for {component['name']}: {e}", file=sys.stderr) + return False + + +def get_current_branch() -> str | None: + """Get the current git branch name, or None if in detached HEAD state.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + branch = result.stdout.strip() + return None if branch == "HEAD" else branch + except subprocess.CalledProcessError: + return None + + +def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool) -> bool: + """Create a PR to remove a stale component using gh CLI.""" + name = component["name"] + path = component["path"] + title = get_removal_pr_title(name) + branch_name = f"remove-stale-{name}" + owners = get_owners(repo_path / path) + + if dry_run: + print(f"[DRY RUN] Would create removal PR: {title}") + print(f" Branch: {branch_name}") + print(f" Removes: {path}") + print(f" Reviewers: {owners}") + return True + + # Save original branch to restore later + original_branch = get_current_branch() + + try: + # Fetch latest from origin + subprocess.run(["git", "fetch", "origin"], check=True, capture_output=True) + + # Get default branch name + result = subprocess.run( + ["gh", "repo", "view", repo, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + capture_output=True, + text=True, + check=True, + ) + default_branch = result.stdout.strip() + + # Create and checkout new branch from default + subprocess.run( + ["git", "checkout", "-B", branch_name, f"origin/{default_branch}"], + check=True, + capture_output=True, + ) + + # Remove the component directory + component_dir = repo_path / path + if component_dir.exists(): + subprocess.run(["git", "rm", "-rf", str(component_dir)], check=True, capture_output=True) + else: + print(f"Component directory not found: {component_dir}", file=sys.stderr) + return False + + # Commit the change + commit_msg = f"Remove stale component: {name}\n\nComponent has not been verified in {component['age_days']} days." + subprocess.run(["git", "commit", "-m", commit_msg], check=True, capture_output=True) + + # Push the branch + subprocess.run(["git", "push", "-u", "origin", branch_name, "--force"], check=True, capture_output=True) + + # Create the PR + body = create_removal_pr_body(component, repo_path) + pr_cmd = [ + "gh", "pr", "create", + "--repo", repo, + "--title", title, + "--body", body, + "--label", REMOVAL_LABEL, + ] + + # Add reviewers if we have owners + if owners: + pr_cmd.extend(["--reviewer", ",".join(owners[:MAX_ASSIGNEES])]) + + result = subprocess.run(pr_cmd, capture_output=True, text=True, check=True) + print(f"Created removal PR: {result.stdout.strip()}") + + return True + except subprocess.CalledProcessError as e: + print(f"Failed to create removal PR for {name}: {e}", file=sys.stderr) + if e.stderr: + print(f" stderr: {e.stderr}", file=sys.stderr) + return False + finally: + # Always restore original branch + if original_branch: + subprocess.run(["git", "checkout", original_branch], capture_output=True) + + +def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> None: + """Handle stale components: issues for warnable or stale components, + and additionally creates PRs for fully stale components.""" + repo_path = REPO_ROOT + results = scan_repo(repo_path) + # Include both warning (270-360 days) and stale (>360 days) components + fully_stale_components = results.get("stale", []) + components_needing_attention = results.get("warning", []) + fully_stale_components + + if not components_needing_attention and not fully_stale_components: + print("No components need attention.") + return + + print(f"Found {len(components_needing_attention)} components needing attention, including {len(fully_stale_components)} fully stale components that should be flagged for removal\n") + + # Handle warning components: create removal warning Issues + if components_needing_attention: + print("=== Warning Components (creating issues) ===") + issues_created, issues_skipped = 0, 0 + for component in components_needing_attention: + if issue_exists(repo, component["name"], token): + print(f"Skipping {component['name']}: issue already exists") + issues_skipped += 1 + continue + if create_issue(repo, component, repo_path, token, dry_run): + issues_created += 1 + print(f"Issues: {issues_created} created, {issues_skipped} skipped\n") + + # Handle stale components: create stale component removal PRs + if fully_stale_components: + print("=== Stale Components (creating removal PRs) ===") + prs_created, prs_skipped = 0, 0 + for component in fully_stale_components: + if removal_pr_exists(repo, component["name"]): + print(f"Skipping {component['name']}: removal PR already exists") + prs_skipped += 1 + continue + if create_removal_pr(repo, component, repo_path, dry_run): + prs_created += 1 + print(f"Removal PRs: {prs_created} created, {prs_skipped} skipped") + + +def main(): + """Handle stale components: create issues for warnings, and removal PRs if stale.""" + parser = argparse.ArgumentParser( + description="Handle stale components: create issues for warnings, and removal PRs if stale." + ) + parser.add_argument("--repo", required=True, help="GitHub repo (e.g., owner/repo)") + parser.add_argument("--token", help="GitHub token (or set GITHUB_TOKEN env var)") + parser.add_argument("--dry-run", action="store_true", help="Print without creating issues/PRs") + args = parser.parse_args() + + token = args.token or os.environ.get("GITHUB_TOKEN") + if not token: + print("Warning: No GitHub token provided. API requests will be subject to rate limiting.", file=sys.stderr) + print("Use --token or set GITHUB_TOKEN environment variable for authenticated requests.", file=sys.stderr) + + handle_stale_components(args.repo, token, args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/stale-component-check.yml b/.github/workflows/stale-component-check.yml index b205ce2e7..0fc42cb18 100644 --- a/.github/workflows/stale-component-check.yml +++ b/.github/workflows/stale-component-check.yml @@ -1,4 +1,3 @@ ---- name: Stale Component Check on: @@ -8,18 +7,19 @@ on: workflow_dispatch: inputs: dry_run: - description: 'Dry run (do not create issues)' + description: 'Dry run (do not create issues/PRs)' required: false default: false type: boolean jobs: check-stale-components: - name: Check for stale components + name: Handle stale components runs-on: ubuntu-24.04 permissions: issues: write - contents: read + contents: write + pull-requests: write steps: - name: Checkout code @@ -32,17 +32,25 @@ jobs: with: python-version: 3.11 - - name: Create issues for stale components + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Handle stale components if: github.event.inputs.dry_run != 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PYTHONPATH: .github/scripts run: | - uv run .github/scripts/create_stale_issues/create_stale_issues.py \ + uv run .github/scripts/stale_component_handler/stale_component_handler.py \ --repo ${{ github.repository }} - name: Dry run (show what would be created) if: github.event.inputs.dry_run == 'true' + env: + PYTHONPATH: .github/scripts run: | - uv run .github/scripts/create_stale_issues/create_stale_issues.py \ + uv run .github/scripts/stale_component_handler/stale_component_handler.py \ --repo ${{ github.repository }} \ --dry-run From 84668ee0794df10cf4184d2ae08cc03551563ce8 Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Thu, 29 Jan 2026 03:03:56 -0500 Subject: [PATCH 06/11] feat(ci): Regnerate category indexes in stale component removal PRs Signed-off-by: Giulio Frasca --- .../scripts/stale_component_handler/README.md | 1 + .../removal_pr_body.md.j2 | 5 +++++ .../stale_component_handler.py | 18 +++++++++++++++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/scripts/stale_component_handler/README.md b/.github/scripts/stale_component_handler/README.md index 4c84a29e6..898ea3464 100644 --- a/.github/scripts/stale_component_handler/README.md +++ b/.github/scripts/stale_component_handler/README.md @@ -16,6 +16,7 @@ Handles components approaching or past their verification deadline. 3. For **stale** components: - Creates a branch `remove-stale-{component-name}` - Removes the component directory + - Regenerates the category README to update the index - Opens a PR with `stale-component-removal` label - Adds component owners as reviewers diff --git a/.github/scripts/stale_component_handler/removal_pr_body.md.j2 b/.github/scripts/stale_component_handler/removal_pr_body.md.j2 index a39fe34b9..d392ca793 100644 --- a/.github/scripts/stale_component_handler/removal_pr_body.md.j2 +++ b/.github/scripts/stale_component_handler/removal_pr_body.md.j2 @@ -8,6 +8,11 @@ This PR removes the component **{{ name }}** which has not been verified in {{ a | Last Verified | {{ last_verified }} | | Days Since Verification | {{ age_days }} | +### Changes + +- Removes `{{ path }}/` directory +- Updates category README to remove component from index + ### Maintainers {{ owners_mention }} diff --git a/.github/scripts/stale_component_handler/stale_component_handler.py b/.github/scripts/stale_component_handler/stale_component_handler.py index c99bd00f8..e29c56995 100755 --- a/.github/scripts/stale_component_handler/stale_component_handler.py +++ b/.github/scripts/stale_component_handler/stale_component_handler.py @@ -5,10 +5,10 @@ """ import argparse +import json import os import subprocess import sys -import json from datetime import datetime, timezone from pathlib import Path @@ -187,6 +187,7 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool print(f"[DRY RUN] Would create removal PR: {title}") print(f" Branch: {branch_name}") print(f" Removes: {path}") + print(f" Updates: {Path(path).parent}/README.md (category index)") print(f" Reviewers: {owners}") return True @@ -221,6 +222,18 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool print(f"Component directory not found: {component_dir}", file=sys.stderr) return False + # Regenerate category README to remove the component from the index + category_dir = component_dir.parent + is_component = "components" in path + try: + index_generator = CategoryIndexGenerator(category_dir, is_component=is_component) + category_readme_content = index_generator.generate() + category_readme_path = category_dir / "README.md" + category_readme_path.write_text(category_readme_content) + subprocess.run(["git", "add", str(category_readme_path)], check=True, capture_output=True) + except Exception as e: + print(f"Warning: Could not regenerate category README: {e}", file=sys.stderr) + # Commit the change commit_msg = f"Remove stale component: {name}\n\nComponent has not been verified in {component['age_days']} days." subprocess.run(["git", "commit", "-m", commit_msg], check=True, capture_output=True) @@ -258,8 +271,7 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> None: - """Handle stale components: issues for warnable or stale components, - and additionally creates PRs for fully stale components.""" + """Handle stale components: issues for warnings, removal PRs for stale.""" repo_path = REPO_ROOT results = scan_repo(repo_path) # Include both warning (270-360 days) and stale (>360 days) components From 78728eccf9e1d0b97b4048dd087a201d944d1bd3 Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Mon, 2 Feb 2026 11:05:29 -0500 Subject: [PATCH 07/11] chore(ci): Sanitize generated stale component removal PRs branch names Signed-off-by: Giulio Frasca --- .../stale_component_handler.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/scripts/stale_component_handler/stale_component_handler.py b/.github/scripts/stale_component_handler/stale_component_handler.py index e29c56995..081bef386 100755 --- a/.github/scripts/stale_component_handler/stale_component_handler.py +++ b/.github/scripts/stale_component_handler/stale_component_handler.py @@ -29,6 +29,27 @@ ISSUE_BODY_TEMPLATE = "issue_body.md.j2" REMOVAL_PR_BODY_TEMPLATE = "removal_pr_body.md.j2" + +def sanitize_branch_name(name: str) -> str: + """Sanitize a string to be a valid git branch name. + + Git branch names cannot contain spaces, ~, ^, :, ?, *, [, \\, or + consecutive dots. They also cannot begin/end with dots or slashes. + """ + import re + + # Replace spaces and invalid characters with hyphens + sanitized = re.sub(r"[\s~^:?*\[\]\\@{}'\"]+", "-", name) + # Replace consecutive dots with a single dot + sanitized = re.sub(r"\.{2,}", ".", sanitized) + # Remove leading/trailing dots, slashes, and hyphens + sanitized = sanitized.strip(".-/") + # Collapse multiple consecutive hyphens into one + sanitized = re.sub(r"-{2,}", "-", sanitized) + # Convert to lowercase for consistency + sanitized = sanitized.lower() + return sanitized + # Maximum issues to check when looking for duplicates (GitHub API max is 100) MAX_ISSUES_PER_PAGE = 100 # GitHub API limits assignees to 10 per issue @@ -180,7 +201,7 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool name = component["name"] path = component["path"] title = get_removal_pr_title(name) - branch_name = f"remove-stale-{name}" + branch_name = f"remove-stale-{sanitize_branch_name(name)}" owners = get_owners(repo_path / path) if dry_run: From c460145361e0d1958bf06c77e0711dd93d5d42c8 Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Mon, 2 Feb 2026 11:15:00 -0500 Subject: [PATCH 08/11] chore: Fail staleness GHA if any issues or PRs fail to create - Still attempts all components before reporting failure Signed-off-by: Giulio Frasca --- .../stale_component_handler.py | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/.github/scripts/stale_component_handler/stale_component_handler.py b/.github/scripts/stale_component_handler/stale_component_handler.py index 081bef386..28cf6330e 100755 --- a/.github/scripts/stale_component_handler/stale_component_handler.py +++ b/.github/scripts/stale_component_handler/stale_component_handler.py @@ -7,6 +7,7 @@ import argparse import json import os +import re import subprocess import sys from datetime import datetime, timezone @@ -29,6 +30,11 @@ ISSUE_BODY_TEMPLATE = "issue_body.md.j2" REMOVAL_PR_BODY_TEMPLATE = "removal_pr_body.md.j2" +# Maximum issues to check when looking for duplicates (GitHub API max is 100) +MAX_ISSUES_PER_PAGE = 100 +# GitHub API limits assignees to 10 per issue +MAX_ASSIGNEES = 10 + def sanitize_branch_name(name: str) -> str: """Sanitize a string to be a valid git branch name. @@ -36,8 +42,6 @@ def sanitize_branch_name(name: str) -> str: Git branch names cannot contain spaces, ~, ^, :, ?, *, [, \\, or consecutive dots. They also cannot begin/end with dots or slashes. """ - import re - # Replace spaces and invalid characters with hyphens sanitized = re.sub(r"[\s~^:?*\[\]\\@{}'\"]+", "-", name) # Replace consecutive dots with a single dot @@ -50,11 +54,6 @@ def sanitize_branch_name(name: str) -> str: sanitized = sanitized.lower() return sanitized -# Maximum issues to check when looking for duplicates (GitHub API max is 100) -MAX_ISSUES_PER_PAGE = 100 -# GitHub API limits assignees to 10 per issue -MAX_ASSIGNEES = 10 - def get_issue_title(component_name: str) -> str: """Generate the standard issue title for a warning component.""" @@ -291,8 +290,11 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool subprocess.run(["git", "checkout", original_branch], capture_output=True) -def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> None: - """Handle stale components: issues for warnings, removal PRs for stale.""" +def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> bool: + """Handle stale components: issues for warnings, removal PRs for stale. + + Returns True if all operations succeeded, False if any failed. + """ repo_path = REPO_ROOT results = scan_repo(repo_path) # Include both warning (270-360 days) and stale (>360 days) components @@ -301,10 +303,14 @@ def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> None if not components_needing_attention and not fully_stale_components: print("No components need attention.") - return + return True print(f"Found {len(components_needing_attention)} components needing attention, including {len(fully_stale_components)} fully stale components that should be flagged for removal\n") + all_succeeded = True + issues_failed = 0 + prs_failed = 0 + # Handle warning components: create removal warning Issues if components_needing_attention: print("=== Warning Components (creating issues) ===") @@ -316,7 +322,10 @@ def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> None continue if create_issue(repo, component, repo_path, token, dry_run): issues_created += 1 - print(f"Issues: {issues_created} created, {issues_skipped} skipped\n") + else: + issues_failed += 1 + all_succeeded = False + print(f"Issues: {issues_created} created, {issues_skipped} skipped, {issues_failed} failed\n") # Handle stale components: create stale component removal PRs if fully_stale_components: @@ -329,7 +338,12 @@ def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> None continue if create_removal_pr(repo, component, repo_path, dry_run): prs_created += 1 - print(f"Removal PRs: {prs_created} created, {prs_skipped} skipped") + else: + prs_failed += 1 + all_succeeded = False + print(f"Removal PRs: {prs_created} created, {prs_skipped} skipped, {prs_failed} failed") + + return all_succeeded def main(): @@ -347,7 +361,9 @@ def main(): print("Warning: No GitHub token provided. API requests will be subject to rate limiting.", file=sys.stderr) print("Use --token or set GITHUB_TOKEN environment variable for authenticated requests.", file=sys.stderr) - handle_stale_components(args.repo, token, args.dry_run) + success = handle_stale_components(args.repo, token, args.dry_run) + if not success: + sys.exit(1) if __name__ == "__main__": From 2539ad7ca87c0195f8262b3721a29f5d3082fc38 Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Mon, 2 Feb 2026 11:23:39 -0500 Subject: [PATCH 09/11] fix(ci): Fix Stale Component Removal PR missing owners - Previously was retrieved after OWNERS file was removed, so script always thought no owners were assigned Signed-off-by: Giulio Frasca --- .../stale_component_handler.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/scripts/stale_component_handler/stale_component_handler.py b/.github/scripts/stale_component_handler/stale_component_handler.py index 28cf6330e..4fd643c0d 100755 --- a/.github/scripts/stale_component_handler/stale_component_handler.py +++ b/.github/scripts/stale_component_handler/stale_component_handler.py @@ -37,7 +37,7 @@ def sanitize_branch_name(name: str) -> str: - """Sanitize a string to be a valid git branch name. + r"""Sanitize a string to be a valid git branch name. Git branch names cannot contain spaces, ~, ^, :, ?, *, [, \\, or consecutive dots. They also cannot begin/end with dots or slashes. @@ -95,12 +95,11 @@ def create_issue_body(component: dict, repo_path: Path) -> str: ) -def create_removal_pr_body(component: dict, repo_path: Path) -> str: +def create_removal_pr_body(component: dict, owners: list[str]) -> str: """Generate the PR body for component removal using Jinja2 template.""" env = Environment(loader=FileSystemLoader(TEMPLATE_DIR)) template = env.get_template(REMOVAL_PR_BODY_TEMPLATE) - owners = get_owners(repo_path / component["path"]) owners_mention = ", ".join(f"@{o}" for o in owners) if owners else "No owners found" return template.render( @@ -255,20 +254,28 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool print(f"Warning: Could not regenerate category README: {e}", file=sys.stderr) # Commit the change - commit_msg = f"Remove stale component: {name}\n\nComponent has not been verified in {component['age_days']} days." + commit_msg = ( + f"Remove stale component: {name}\n\nComponent has not been verified in {component['age_days']} days." + ) subprocess.run(["git", "commit", "-m", commit_msg], check=True, capture_output=True) # Push the branch subprocess.run(["git", "push", "-u", "origin", branch_name, "--force"], check=True, capture_output=True) - # Create the PR - body = create_removal_pr_body(component, repo_path) + # Create the PR (use owners read before component was removed) + body = create_removal_pr_body(component, owners) pr_cmd = [ - "gh", "pr", "create", - "--repo", repo, - "--title", title, - "--body", body, - "--label", REMOVAL_LABEL, + "gh", + "pr", + "create", + "--repo", + repo, + "--title", + title, + "--body", + body, + "--label", + REMOVAL_LABEL, ] # Add reviewers if we have owners @@ -305,7 +312,10 @@ def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> bool print("No components need attention.") return True - print(f"Found {len(components_needing_attention)} components needing attention, including {len(fully_stale_components)} fully stale components that should be flagged for removal\n") + print( + f"Found {len(components_needing_attention)} components needing attention, including " + f"{len(fully_stale_components)} fully stale components that should be flagged for removal\n" + ) all_succeeded = True issues_failed = 0 From c631bb621cfc298342fa1414464bd8e5b14500cf Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Thu, 12 Feb 2026 20:33:03 -0500 Subject: [PATCH 10/11] chore: Add more robust error checking to stale component GHA Signed-off-by: Giulio Frasca --- .../stale_component_handler.py | 99 +++++++++++++++++-- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/.github/scripts/stale_component_handler/stale_component_handler.py b/.github/scripts/stale_component_handler/stale_component_handler.py index 4fd643c0d..adff3af56 100755 --- a/.github/scripts/stale_component_handler/stale_component_handler.py +++ b/.github/scripts/stale_component_handler/stale_component_handler.py @@ -74,6 +74,8 @@ def get_owners(component_path: Path) -> list[str]: owners = yaml.safe_load(owners_file.read_text()) return owners.get("approvers", []) if owners else [] except Exception: + print(f"::warning:: Error: Could not read OWNERS file for component {component_path}. ", file=sys.stderr) + print(f"::warning:: No owners will be assigned to the issue for {component_path}.", file=sys.stderr) return [] @@ -127,8 +129,13 @@ def issue_exists(repo: str, component_name: str, token: str | None) -> bool: resp.raise_for_status() return any(issue["title"] == expected_title for issue in resp.json()) except Exception as e: - print(f"Failed to check for existing issue: {e}", file=sys.stderr) - return False + print(f"::error::Failed to check for existing issue: {e}", file=sys.stderr) + print( + "::error:: Cannot verify if issue already exists or not. Will assume it does to prevent " + "creating duplicate issues and skip this component.", + file=sys.stderr, + ) + return True def removal_pr_exists(repo: str, component_name: str) -> bool: @@ -144,8 +151,13 @@ def removal_pr_exists(repo: str, component_name: str) -> bool: prs = json.loads(result.stdout) return any(pr["title"] == expected_title for pr in prs) except subprocess.CalledProcessError as e: - print(f"Failed to check for existing PR: {e}", file=sys.stderr) - return False + print(f"::error::Failed to check for existing PR: {e}", file=sys.stderr) + print( + "::error:: Cannot verify if PR already exists or not. Will assume it does to prevent " + "creating duplicate PRs and skip this component.", + file=sys.stderr, + ) + return True def create_issue(repo: str, component: dict, repo_path: Path, token: str | None, dry_run: bool) -> bool: @@ -213,6 +225,7 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool # Save original branch to restore later original_branch = get_current_branch() + branch_pushed = False try: # Fetch latest from origin subprocess.run(["git", "fetch", "origin"], check=True, capture_output=True) @@ -261,6 +274,7 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool # Push the branch subprocess.run(["git", "push", "-u", "origin", branch_name, "--force"], check=True, capture_output=True) + branch_pushed = True # Create the PR (use owners read before component was removed) body = create_removal_pr_body(component, owners) @@ -290,6 +304,17 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool print(f"Failed to create removal PR for {name}: {e}", file=sys.stderr) if e.stderr: print(f" stderr: {e.stderr}", file=sys.stderr) + # Clean up the remote branch if it was pushed but PR creation failed + if branch_pushed: + try: + subprocess.run( + ["git", "push", "origin", "--delete", branch_name], + check=True, + capture_output=True, + ) + print(f"Cleaned up orphaned remote branch: {branch_name}") + except subprocess.CalledProcessError: + print(f"::warning:: Failed to clean up remote branch: {branch_name}", file=sys.stderr) return False finally: # Always restore original branch @@ -297,11 +322,54 @@ def create_removal_pr(repo: str, component: dict, repo_path: Path, dry_run: bool subprocess.run(["git", "checkout", original_branch], capture_output=True) +def ensure_labels_exist(repo: str, token: str | None, dry_run: bool) -> bool: + """Verify that required GitHub labels exist in the repo. + + Checks for ISSUE_LABEL and REMOVAL_LABEL. Returns True if both + exist (or if running in dry-run mode), False otherwise. + """ + required_labels = [ISSUE_LABEL, REMOVAL_LABEL] + headers = {"Accept": "application/vnd.github.v3+json"} + if token: + headers["Authorization"] = f"token {token}" + + missing = [] + for label in required_labels: + try: + resp = requests.get( + f"https://api.github.com/repos/{repo}/labels/{label}", + headers=headers, + timeout=30, + ) + if resp.status_code == 404: + missing.append(label) + else: + resp.raise_for_status() + except requests.exceptions.RequestException as e: + print(f"::error:: Failed to check for label '{label}': {e}", file=sys.stderr) + return False + + if missing: + msg = ", ".join(f"'{label}'" for label in missing) + if dry_run: + print(f"::warning:: Labels {msg} do not exist in {repo}. They would need to be created before a real run.") + return True + print(f"::error:: Required labels {msg} do not exist in {repo}.", file=sys.stderr) + print("::error:: Create the missing labels and re-run.", file=sys.stderr) + return False + + print(f"Preflight: all required labels exist ({', '.join(required_labels)})") + return True + + def handle_stale_components(repo: str, token: str | None, dry_run: bool) -> bool: """Handle stale components: issues for warnings, removal PRs for stale. Returns True if all operations succeeded, False if any failed. """ + if not ensure_labels_exist(repo, token, dry_run): + return False + repo_path = REPO_ROOT results = scan_repo(repo_path) # Include both warning (270-360 days) and stale (>360 days) components @@ -368,8 +436,27 @@ def main(): token = args.token or os.environ.get("GITHUB_TOKEN") if not token: - print("Warning: No GitHub token provided. API requests will be subject to rate limiting.", file=sys.stderr) - print("Use --token or set GITHUB_TOKEN environment variable for authenticated requests.", file=sys.stderr) + if args.dry_run: + print( + "::warning:: Warning: No GitHub token provided. API requests will be subject to rate limiting.", + file=sys.stderr, + ) + print( + "::warning:: Use --token or set GITHUB_TOKEN environment variable for authenticated requests.", + file=sys.stderr, + ) + else: + print( + "::error:: Error: Required GitHub token not provided. " + "Will not be able to create staleness Issues and PRs.", + file=sys.stderr, + ) + print( + "::error:: Use --token or set GITHUB_TOKEN environment variable for authenticated requests.", + file=sys.stderr, + ) + print("::error:: Stopping...", file=sys.stderr) + sys.exit(1) success = handle_stale_components(args.repo, token, args.dry_run) if not success: From 02011e4dedbc276bb2e12d0e7e07c35a89d7a15e Mon Sep 17 00:00:00 2001 From: Giulio Frasca Date: Thu, 12 Feb 2026 20:49:20 -0500 Subject: [PATCH 11/11] test: Add unit tests for stale_component_handler GHA Signed-off-by: Giulio Frasca --- .../stale_component_handler/__init__.py | 1 + .../stale_component_handler/tests/__init__.py | 1 + .../tests/test_stale_component_handler.py | 325 ++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 .github/scripts/stale_component_handler/__init__.py create mode 100644 .github/scripts/stale_component_handler/tests/__init__.py create mode 100644 .github/scripts/stale_component_handler/tests/test_stale_component_handler.py diff --git a/.github/scripts/stale_component_handler/__init__.py b/.github/scripts/stale_component_handler/__init__.py new file mode 100644 index 000000000..6d63b6cff --- /dev/null +++ b/.github/scripts/stale_component_handler/__init__.py @@ -0,0 +1 @@ +# Stale component handler module diff --git a/.github/scripts/stale_component_handler/tests/__init__.py b/.github/scripts/stale_component_handler/tests/__init__.py new file mode 100644 index 000000000..d27675ab2 --- /dev/null +++ b/.github/scripts/stale_component_handler/tests/__init__.py @@ -0,0 +1 @@ +# Tests for stale_component_handler module diff --git a/.github/scripts/stale_component_handler/tests/test_stale_component_handler.py b/.github/scripts/stale_component_handler/tests/test_stale_component_handler.py new file mode 100644 index 000000000..ccd55bce4 --- /dev/null +++ b/.github/scripts/stale_component_handler/tests/test_stale_component_handler.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Unit tests for stale_component_handler.py.""" + +from __future__ import annotations + +import json +import subprocess +from unittest.mock import MagicMock, patch + +import pytest +import requests +import yaml + +# Import the module object so we can use patch.object on it. +# String-based @patch() paths don't work because ".github" in the +# filesystem path confuses Python's resolve_name. +from .. import stale_component_handler as sch + +# Also import individual functions for direct calls +from ..stale_component_handler import ( + get_issue_title, + get_removal_pr_title, + sanitize_branch_name, +) + + +class TestSanitizeBranchName: + """Tests for sanitize_branch_name.""" + + @pytest.mark.parametrize( + "input_name, expected", + [ + ("my-component", "my-component"), + ("my component name", "my-component-name"), + ("comp~name^v2", "comp-name-v2"), + ("comp..name...test", "comp.name.test"), + (".comp-name.", "comp-name"), + ("comp---name", "comp-name"), + ("My-Component", "my-component"), + (" My Comp[lex]*Na?me ", "my-comp-lex-na-me"), + ("...", ""), + ], + ) + def test_sanitize(self, input_name, expected): + """Verify branch name sanitization for various inputs.""" + assert sanitize_branch_name(input_name) == expected + + +class TestTitleGenerators: + """Tests for issue and PR title generation.""" + + def test_get_issue_title(self): + """Verify issue title format.""" + assert get_issue_title("my-comp") == "Component `my-comp` needs verification" + + def test_get_removal_pr_title(self): + """Verify removal PR title format.""" + assert get_removal_pr_title("my-comp") == "chore: Remove stale component `my-comp`" + + +class TestGetOwners: + """Tests for get_owners.""" + + def test_returns_approvers(self, tmp_path): + """Return approvers list from a valid OWNERS file.""" + owners_data = {"approvers": ["alice", "bob"]} + (tmp_path / "OWNERS").write_text(yaml.dump(owners_data)) + assert sch.get_owners(tmp_path) == ["alice", "bob"] + + def test_missing_owners_file(self, tmp_path): + """Return empty list when OWNERS file does not exist.""" + assert sch.get_owners(tmp_path) == [] + + def test_empty_owners_file(self, tmp_path): + """Return empty list when OWNERS file is empty.""" + (tmp_path / "OWNERS").write_text("") + assert sch.get_owners(tmp_path) == [] + + def test_no_approvers_key(self, tmp_path): + """Return empty list when OWNERS file has no approvers key.""" + (tmp_path / "OWNERS").write_text(yaml.dump({"reviewers": ["alice"]})) + assert sch.get_owners(tmp_path) == [] + + def test_malformed_yaml(self, tmp_path): + """Return empty list when OWNERS file contains invalid YAML.""" + (tmp_path / "OWNERS").write_text("::not::valid::yaml[[[") + assert sch.get_owners(tmp_path) == [] + + +class TestIssueExists: + """Tests for issue_exists.""" + + def test_returns_true_when_matching_issue_found(self): + """Return True when an open issue with the expected title exists.""" + mock_resp = MagicMock() + mock_resp.json.return_value = [{"title": get_issue_title("my-comp")}] + mock_resp.raise_for_status = MagicMock() + + with patch.object(sch.requests, "get", return_value=mock_resp): + assert sch.issue_exists("owner/repo", "my-comp", "fake-token") is True + + def test_returns_false_when_no_match(self): + """Return False when no open issue matches the expected title.""" + mock_resp = MagicMock() + mock_resp.json.return_value = [{"title": "Some other issue"}] + mock_resp.raise_for_status = MagicMock() + + with patch.object(sch.requests, "get", return_value=mock_resp): + assert sch.issue_exists("owner/repo", "my-comp", "fake-token") is False + + def test_returns_true_on_api_error(self): + """On failure, assume issue exists to prevent duplicates.""" + with patch.object(sch.requests, "get", side_effect=requests.exceptions.ConnectionError("fail")): + assert sch.issue_exists("owner/repo", "my-comp", "fake-token") is True + + def test_no_auth_header_without_token(self): + """Omit Authorization header when no token is provided.""" + mock_resp = MagicMock() + mock_resp.json.return_value = [] + mock_resp.raise_for_status = MagicMock() + + with patch.object(sch.requests, "get", return_value=mock_resp) as mock_get: + sch.issue_exists("owner/repo", "my-comp", None) + _, kwargs = mock_get.call_args + assert "Authorization" not in kwargs["headers"] + + +class TestRemovalPrExists: + """Tests for removal_pr_exists.""" + + def test_returns_true_when_matching_pr_found(self): + """Return True when an open PR with the expected title exists.""" + mock_result = MagicMock( + stdout=json.dumps([{"title": get_removal_pr_title("my-comp")}]), + ) + with patch.object(sch.subprocess, "run", return_value=mock_result): + assert sch.removal_pr_exists("owner/repo", "my-comp") is True + + def test_returns_false_when_no_match(self): + """Return False when no open PR matches the expected title.""" + mock_result = MagicMock(stdout=json.dumps([])) + with patch.object(sch.subprocess, "run", return_value=mock_result): + assert sch.removal_pr_exists("owner/repo", "my-comp") is False + + def test_returns_true_on_cli_failure(self): + """On failure, assume PR exists to prevent duplicates.""" + with patch.object(sch.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "gh")): + assert sch.removal_pr_exists("owner/repo", "my-comp") is True + + +class TestGetCurrentBranch: + """Tests for get_current_branch.""" + + def test_returns_branch_name(self): + """Return the branch name when on a named branch.""" + with patch.object(sch.subprocess, "run", return_value=MagicMock(stdout="main\n")): + assert sch.get_current_branch() == "main" + + def test_returns_none_for_detached_head(self): + """Return None when in detached HEAD state.""" + with patch.object(sch.subprocess, "run", return_value=MagicMock(stdout="HEAD\n")): + assert sch.get_current_branch() is None + + def test_returns_none_on_error(self): + """Return None when git command fails.""" + with patch.object(sch.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "git")): + assert sch.get_current_branch() is None + + +class TestEnsureLabelsExist: + """Tests for ensure_labels_exist.""" + + def test_both_labels_exist(self): + """Pass when both required labels exist in the repo.""" + mock_resp = MagicMock(status_code=200, raise_for_status=MagicMock()) + + with patch.object(sch.requests, "get", return_value=mock_resp) as mock_get: + assert sch.ensure_labels_exist("owner/repo", "fake-token", dry_run=False) is True + assert mock_get.call_count == 2 + + def test_missing_label_fails(self): + """Fail when a required label is missing.""" + responses = [ + MagicMock(status_code=200, raise_for_status=MagicMock()), + MagicMock(status_code=404), + ] + with patch.object(sch.requests, "get", side_effect=responses): + assert sch.ensure_labels_exist("owner/repo", "fake-token", dry_run=False) is False + + def test_missing_label_warns_in_dry_run(self): + """Warn but pass when a label is missing in dry-run mode.""" + responses = [ + MagicMock(status_code=200, raise_for_status=MagicMock()), + MagicMock(status_code=404), + ] + with patch.object(sch.requests, "get", side_effect=responses): + assert sch.ensure_labels_exist("owner/repo", "fake-token", dry_run=True) is True + + def test_api_error_fails(self): + """Fail when the GitHub API request errors out.""" + with patch.object(sch.requests, "get", side_effect=requests.exceptions.ConnectionError("fail")): + assert sch.ensure_labels_exist("owner/repo", "fake-token", dry_run=False) is False + + +class TestCreateIssue: + """Tests for create_issue.""" + + COMPONENT = { + "name": "my-comp", + "path": "components/category/my-comp", + "last_verified": "2024-01-01", + "age_days": 300, + } + + def test_dry_run_returns_true(self, tmp_path): + """Return True without calling the API in dry-run mode.""" + comp_dir = tmp_path / "components" / "category" / "my-comp" + comp_dir.mkdir(parents=True) + assert sch.create_issue("owner/repo", self.COMPONENT, tmp_path, "fake-token", dry_run=True) is True + + def test_success(self, tmp_path): + """Return True when issue creation succeeds.""" + comp_dir = tmp_path / "components" / "category" / "my-comp" + comp_dir.mkdir(parents=True) + + mock_resp = MagicMock() + mock_resp.json.return_value = {"html_url": "https://github.com/owner/repo/issues/1"} + mock_resp.raise_for_status = MagicMock() + + with ( + patch.object(sch.requests, "post", return_value=mock_resp) as mock_post, + patch.object(sch, "create_issue_body", return_value="body text"), + ): + assert sch.create_issue("owner/repo", self.COMPONENT, tmp_path, "fake-token", dry_run=False) is True + mock_post.assert_called_once() + + def test_api_failure_returns_false(self, tmp_path): + """Return False when the API request fails.""" + comp_dir = tmp_path / "components" / "category" / "my-comp" + comp_dir.mkdir(parents=True) + + with ( + patch.object(sch.requests, "post", side_effect=requests.exceptions.HTTPError("403")), + patch.object(sch, "create_issue_body", return_value="body text"), + ): + assert sch.create_issue("owner/repo", self.COMPONENT, tmp_path, "fake-token", dry_run=False) is False + + +class TestCreateRemovalPrCleanup: + """Tests for create_removal_pr orphaned branch cleanup.""" + + COMPONENT = { + "name": "my-comp", + "path": "components/category/my-comp", + "last_verified": "2024-01-01", + "age_days": 400, + } + + def test_dry_run_returns_true(self, tmp_path): + """Return True without side effects in dry-run mode.""" + comp_dir = tmp_path / self.COMPONENT["path"] + comp_dir.mkdir(parents=True) + assert sch.create_removal_pr("owner/repo", self.COMPONENT, tmp_path, dry_run=True) is True + + def test_cleans_up_remote_branch_on_pr_failure(self, tmp_path): + """When push succeeds but gh pr create fails, the remote branch should be deleted.""" + comp_dir = tmp_path / self.COMPONENT["path"] + comp_dir.mkdir(parents=True) + + def side_effect(cmd, **kwargs): + cmd_list = cmd if isinstance(cmd, list) else [cmd] + # gh repo view (get default branch) + if "gh" in cmd_list and "repo" in cmd_list and "view" in cmd_list: + return MagicMock(stdout="main\n") + # gh pr create – simulate failure + if "gh" in cmd_list and "pr" in cmd_list and "create" in cmd_list: + raise subprocess.CalledProcessError(1, cmd, stderr="PR creation failed") + # git push --delete (cleanup) – should succeed + if "push" in cmd_list and "--delete" in cmd_list: + return MagicMock(returncode=0) + # All other git commands succeed + return MagicMock(returncode=0) + + with ( + patch.object(sch.subprocess, "run", side_effect=side_effect) as mock_run, + patch.object(sch, "get_current_branch", return_value="main"), + patch.object(sch, "get_owners", return_value=["alice"]), + ): + result = sch.create_removal_pr("owner/repo", self.COMPONENT, tmp_path, dry_run=False) + assert result is False + + # Verify cleanup was attempted + delete_calls = [c for c in mock_run.call_args_list if "--delete" in (c[0][0] if c[0] else [])] + assert len(delete_calls) == 1 + assert "remove-stale-my-comp" in delete_calls[0][0][0] + + def test_no_cleanup_when_push_fails(self, tmp_path): + """When push itself fails, no remote cleanup should be attempted.""" + comp_dir = tmp_path / self.COMPONENT["path"] + comp_dir.mkdir(parents=True) + + def side_effect(cmd, **kwargs): + cmd_list = cmd if isinstance(cmd, list) else [cmd] + if "gh" in cmd_list and "repo" in cmd_list and "view" in cmd_list: + return MagicMock(stdout="main\n") + # git push – simulate failure + if "push" in cmd_list and "-u" in cmd_list: + raise subprocess.CalledProcessError(1, cmd, stderr="push failed") + return MagicMock(returncode=0) + + with ( + patch.object(sch.subprocess, "run", side_effect=side_effect) as mock_run, + patch.object(sch, "get_current_branch", return_value="main"), + patch.object(sch, "get_owners", return_value=[]), + ): + result = sch.create_removal_pr("owner/repo", self.COMPONENT, tmp_path, dry_run=False) + assert result is False + + # No --delete call should have been made + delete_calls = [c for c in mock_run.call_args_list if "--delete" in (c[0][0] if c[0] else [])] + assert len(delete_calls) == 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])