Skip to content

feat(ci): Add scheduled GitHub Action to create issues for stale components#86

Open
gmfrasca wants to merge 11 commits intokubeflow:mainfrom
gmfrasca:lastverified-gha
Open

feat(ci): Add scheduled GitHub Action to create issues for stale components#86
gmfrasca wants to merge 11 commits intokubeflow:mainfrom
gmfrasca:lastverified-gha

Conversation

@gmfrasca
Copy link
Member

@gmfrasca gmfrasca commented Jan 28, 2026

Description of your changes:
Adds a weekly scheduled GitHub Action that automatically identifies components approaching or past their verification deadline and creates GitHub issues to notify maintainers.

  • Creates issues for components in warning (270-360 days) or stale (>360 days) status
  • Creates removal PRs for components in full stale (>360 days) status
  • Assigns component owners from OWNERS file as issue/pr assignees (up to 10)
  • Labels issues with stale-component
  • Labels removal PRs with stale-component-removal
  • Checks for existing open issues/prs to prevent duplicates
  • Dry-run mode for safe testing
  • NOTE: Requires a repo admin to create the labels stale-component and stale-component-removal

Checklist:

Pre-Submission Checklist

  • All tests and CI checks pass
  • Pre-commit hooks pass without errors
  • You have signed off your commits
  • The title for your pull request (PR) should follow our title convention.

@google-oss-prow
Copy link

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please ask for approval from gmfrasca. For more information see the Kubernetes Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

from jinja2 import Environment, FileSystemLoader

# Add repo root to path so we can import from scripts/
REPO_ROOT = Path(__file__).parent.parent.parent.parent
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not a fan of this, but the hidden directory designation of .github makes navigating and pulling in util scripts from the scripts/ dir difficult. may address this in a later PR

@gmfrasca gmfrasca force-pushed the lastverified-gha branch 2 times, most recently from c48a392 to 09f9373 Compare February 2, 2026 16:07
@google-oss-prow google-oss-prow bot added size/XL and removed size/L labels Feb 2, 2026
@gmfrasca gmfrasca changed the title WIP: feat(ci): Add scheduled GitHub Action to create issues for stale components feat(ci): Add scheduled GitHub Action to create issues for stale components Feb 2, 2026
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could lead to duplicate issues.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could lead to duplicate PRs.

Comment on lines +295 to +297
# Always restore original branch
if original_branch:
subprocess.run(["git", "checkout", original_branch], capture_output=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If create_removal_pr() fails after pushing the branch but before creating the PR, the remote branch will remain orphaned. The finally block only restores the local branch.

Comment on lines +20 to +25
# 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you create a __init__.py file you can get rid of that sys.path.append and use relative imports.

https://github.com/kubeflow/pipelines-components/tree/main/scripts#import-conventions

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unfortunately i don't think that works here, because .github is an invalid python package name and therefore won't be able to make a singular package with this and the scripts/ package together (therefore breaking relative imports). The syspath append hack is a workaround for this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add unit tests?

- 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure that this label exists.

- 🔴 **Stale (>360 days)**: Creates PRs to remove the component

2. For **warning** components:
- Creates issues with `stale-component` label
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure that this label exists.

owners = yaml.safe_load(owners_file.read_text())
return owners.get("approvers", []) if owners else []
except Exception:
return []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to raise the exception. But that would fail the workflow and that would be worst than using wrong owners.

Optional: Can you please check if it's possible to log a GitHub warning message easily here?

Comment on lines 370 to 372
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without a token, the script will fail (not just rate-limit) on create_issue() and gh pr create. Consider failing fast:

Suggested change
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 not token and not args.dry_run:
print("Error: GITHUB_TOKEN required.", file=sys.stderr)
sys.exit(1)

Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
- Stale components are ones that cross first, earlier threshold
- Original threshold is for components flagged for deletion

Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
- Add functionality to the stalness check ci script to create
  component removal PRs if flagged as fully stale

Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
- Still attempts all components before reporting failure

Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
- Previously was retrieved after OWNERS file was removed, so script always thought no
  owners were assigned

Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
Copilot AI review requested due to automatic review settings February 13, 2026 02:02
Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
Signed-off-by: Giulio Frasca <gfrasca@redhat.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds automation to periodically detect components nearing/past verification deadlines and open GitHub issues / removal PRs to notify maintainers and drive cleanup.

Changes:

  • Adds a weekly scheduled + manually-triggerable GitHub Actions workflow to run the stale component handler.
  • Introduces a Python handler that scans component freshness, creates labeled issues, and opens labeled removal PRs for fully stale components.
  • Adds Jinja2 templates and unit tests for the handler logic.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
.github/workflows/stale-component-check.yml Schedules and runs the stale component handler with appropriate permissions and a dry-run option.
.github/scripts/stale_component_handler/stale_component_handler.py Core logic for scanning staleness, preventing duplicates, creating issues, and creating removal PRs.
.github/scripts/stale_component_handler/tests/test_stale_component_handler.py Unit tests for branch name sanitization, title generation, owners parsing, duplicate checks, label checks, and PR cleanup behavior.
.github/scripts/stale_component_handler/tests/init.py Test package marker for pytest discovery/import behavior.
.github/scripts/stale_component_handler/issue_body.md.j2 Issue body template for “needs verification” issues.
.github/scripts/stale_component_handler/removal_pr_body.md.j2 PR body template for stale component removal PRs.
.github/scripts/stale_component_handler/init.py Package marker for the handler module.
.github/scripts/stale_component_handler/README.md Usage and behavior documentation for the handler script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

expected_title = get_removal_pr_title(component_name)
try:
result = subprocess.run(
["gh", "pr", "list", "--repo", repo, "--state", "open", "--json", "title"],
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gh pr list defaults to a limited number of PRs (commonly 30). Since this call doesn’t pass --limit, removal_pr_exists() may miss an existing open removal PR in a busy repo and create duplicates. Consider adding an explicit --limit (and/or searching by headRefName/branch) to make duplicate prevention reliable.

Suggested change
["gh", "pr", "list", "--repo", repo, "--state", "open", "--json", "title"],
["gh", "pr", "list", "--repo", repo, "--state", "open", "--limit", "200", "--json", "title"],

Copilot uses AI. Check for mistakes.
Comment on lines +289 to +292
"--body",
body,
"--label",
REMOVAL_LABEL,
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says removal PRs are assigned to component owners, but the implementation adds owners as reviewers (--reviewer). If assignees are required, use --assignee (and possibly keep reviewers too), or update the PR description to match the behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +320 to +322
# Always restore original branch
if original_branch:
subprocess.run(["git", "checkout", original_branch], capture_output=True)
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If create_removal_pr() fails after modifying the working tree (e.g., after git rm but before a successful commit/push), the repo can be left dirty and/or on the removal branch. Since the handler loops over multiple stale components in one run, a single failure can cause subsequent components to fail (e.g., checkout errors due to local changes). Consider adding a cleanup step in finally (hard reset/clean + checkout to a known ref) so each component starts from a clean state.

Suggested change
# Always restore original branch
if original_branch:
subprocess.run(["git", "checkout", original_branch], capture_output=True)
# Always restore repository to a clean state on the original branch
if original_branch:
# Force checkout to avoid being blocked by local changes
subprocess.run(["git", "checkout", "-f", original_branch], capture_output=True)
# Best-effort cleanup of any local changes created during PR creation
try:
subprocess.run(
["git", "reset", "--hard"],
check=True,
capture_output=True,
)
subprocess.run(
["git", "clean", "-fd"],
check=True,
capture_output=True,
)
except subprocess.CalledProcessError as cleanup_err:
print("::warning:: Failed to fully reset working tree after removal PR attempt", file=sys.stderr)
if cleanup_err.stderr:
print(f" stderr: {cleanup_err.stderr}", file=sys.stderr)

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +55
- name: Dry run (show what would be created)
if: github.event.inputs.dry_run == 'true'
env:
PYTHONPATH: .github/scripts
run: |
uv run .github/scripts/stale_component_handler/stale_component_handler.py \
--repo ${{ github.repository }} \
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dry-run step doesn’t set GITHUB_TOKEN, but the script still calls the GitHub API (label preflight + duplicate checks). Without a token, dry-run executions can get rate-limited or behave differently (e.g., skipping everything on API error). Consider setting GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} for the dry-run step as well so output is representative.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +42
- name: Handle stale components
if: github.event.inputs.dry_run != 'true'
env:
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

github.event.inputs.dry_run is referenced in if: conditions, but scheduled runs don’t have workflow_dispatch inputs. This can lead to expression-evaluation errors or unexpected truthiness. Consider guarding with github.event_name == 'workflow_dispatch' using short-circuit logic (e.g., run the non-dry step when not workflow_dispatch, and run the dry step only when workflow_dispatch && dry_run).

Copilot uses AI. Check for mistakes.
import yaml
from jinja2 import Environment, FileSystemLoader

# utils module sets up sys.path and re-exports from scripts/lib/discovery
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment mentions a "utils module" that sets up sys.path, but the code below directly computes REPO_ROOT and appends it. Updating/removing this comment would avoid confusion when maintaining this script.

Suggested change
# utils module sets up sys.path and re-exports from scripts/lib/discovery
# Add repository root to sys.path so we can import internal scripts

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +5

**If not verified within the next {{ 360 - age_days }} days, a PR will be created to remove this component.**
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue_body.md.j2 computes 360 - age_days to show “days remaining”. For fully stale components (age_days > 360) this becomes negative, producing misleading instructions (e.g., “within the next -40 days”). Either avoid creating this warning issue for fully-stale components, or adjust the template to handle age_days >= 360 with different messaging / a max(0, …) style calculation.

Suggested change
**If not verified within the next {{ 360 - age_days }} days, a PR will be created to remove this component.**
{% if age_days < 360 %}
**If not verified within the next {{ 360 - age_days }} days, a PR will be created to remove this component.**
{% else %}
**This component has exceeded the staleness threshold, and a PR may be created at any time to remove this component if it is not re-verified.**
{% endif %}

Copilot uses AI. Check for mistakes.

steps:
- name: Checkout code
uses: actions/checkout@v4
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow uses actions/checkout@v4, while the rest of the repo’s workflows consistently use actions/checkout@v6. Aligning the version helps keep CI dependencies consistent across workflows.

Suggested change
uses: actions/checkout@v4
uses: actions/checkout@v6

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +130
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())
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue_exists() fetches only the first page of open issues with the stale-component label (per_page=100) and is called once per component. If there are >100 open labeled issues, duplicates can slip through; and even below that, this creates N identical API calls. Consider fetching all relevant issues once (or using the Search API) and reusing the result across components, with pagination/Link handling as needed.

Suggested change
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())
# Cache of open issues with the configured label, keyed by (repo, token)
_OPEN_LABELED_ISSUES_CACHE: dict[tuple[str, str | None], list[dict]] = {}
def _get_open_labeled_issues(repo: str, token: str | None) -> list[dict]:
"""Fetch and cache all open issues with the configured label for the given repo."""
cache_key = (repo, token)
if cache_key in _OPEN_LABELED_ISSUES_CACHE:
return _OPEN_LABELED_ISSUES_CACHE[cache_key]
headers = {"Accept": "application/vnd.github.v3+json"}
if token:
headers["Authorization"] = f"token {token}"
all_issues: list[dict] = []
page = 1
while True:
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,
"page": page,
},
timeout=30,
)
resp.raise_for_status()
page_issues = resp.json()
if not page_issues:
break
all_issues.extend(page_issues)
if len(page_issues) < MAX_ISSUES_PER_PAGE:
break
page += 1
_OPEN_LABELED_ISSUES_CACHE[cache_key] = all_issues
return all_issues
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)
try:
issues = _get_open_labeled_issues(repo, token)
return any(issue.get("title") == expected_title for issue in issues)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants