From f458ecc7be1bf43ae2d17a6f05512869bf336e15 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Thu, 30 Oct 2025 17:29:20 +0800 Subject: [PATCH] Scan PRs cleanup (#626) Signed-off-by: Glenn Jocher Co-authored-by: UltralyticsAssistant --- .github/workflows/scan-prs.yml | 2 +- README.md | 2 +- actions/scan_prs.py | 106 ++++++++++++++++++++------------- scan-prs/README.md | 14 ++--- scan-prs/action.yml | 2 +- 5 files changed, 74 insertions(+), 52 deletions(-) diff --git a/.github/workflows/scan-prs.yml b/.github/workflows/scan-prs.yml index 5096394..2431817 100644 --- a/.github/workflows/scan-prs.yml +++ b/.github/workflows/scan-prs.yml @@ -19,4 +19,4 @@ jobs: steps: - uses: ultralytics/actions/scan-prs@main with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets._GITHUB_TOKEN }} diff --git a/README.md b/README.md index a93efad..a3b1dc3 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ List open PRs across an organization and auto-merge eligible Dependabot PRs. with: token: ${{ secrets.GITHUB_TOKEN }} org: ultralytics # Optional: defaults to ultralytics - visibility: public # Optional: public, private, internal, or all + visibility: private,internal # Optional: public, private, internal, all, or comma-separated ``` [**šŸ“– Full Documentation →**](scan-prs/README.md) diff --git a/actions/scan_prs.py b/actions/scan_prs.py index ee798e7..11c1e31 100644 --- a/actions/scan_prs.py +++ b/actions/scan_prs.py @@ -24,32 +24,70 @@ def get_phase_emoji(age_days): return "šŸ”“", f"{age_days} days" +def parse_visibility(visibility_input, repo_visibility): + """Parse and validate visibility settings with security checks.""" + valid = {"public", "private", "internal", "all"} + stripped = [v.strip() for v in visibility_input.lower().split(",") if v.strip()] + repo_visibility = (repo_visibility or "").lower() + + # Warn about invalid values + if invalid := [v for v in stripped if v not in valid]: + print(f"āš ļø Invalid visibility values: {', '.join(invalid)} - ignoring") + + visibility_list = [v for v in stripped if v in valid] + if not visibility_list: + print("āš ļø No valid visibility values, defaulting to 'public'") + return ["public"] + + # Security: public repos can only scan public repos + if repo_visibility == "public" and visibility_list != ["public"]: + print("āš ļø Security: Public repo cannot scan non-public repos. Restricting to public only.") + return ["public"] + + return visibility_list + + +def get_repo_filter(visibility_list): + """Return filtering strategy for repo visibility.""" + if len(visibility_list) == 1 and visibility_list[0] != "all": + return {"flag": ["--visibility", visibility_list[0]], "filter": None, "str": visibility_list[0]} + + filter_set = {"public", "private", "internal"} if "all" in visibility_list else set(visibility_list) + return { + "flag": [], + "filter": filter_set, + "str": "all" if "all" in visibility_list else ", ".join(sorted(visibility_list)), + } + + +def get_status_checks(rollup): + """Extract and validate status checks from rollup, return failed checks.""" + checks = rollup if isinstance(rollup, list) else rollup.get("contexts", []) if isinstance(rollup, dict) else [] + return [c for c in checks if c.get("conclusion") not in ["SUCCESS", "SKIPPED", "NEUTRAL"]] + + def run(): """List open PRs across organization and auto-merge eligible Dependabot PRs.""" - # Get and validate settings org = os.getenv("ORG", "ultralytics") - visibility = os.getenv("VISIBILITY", "public").lower() - repo_visibility = os.getenv("REPO_VISIBILITY", "public").lower() - valid_visibilities = {"public", "private", "internal", "all"} + visibility_list = parse_visibility(os.getenv("VISIBILITY", "public"), os.getenv("REPO_VISIBILITY", "public")) + filter_config = get_repo_filter(visibility_list) - if visibility not in valid_visibilities: - print(f"āš ļø Invalid visibility '{visibility}', defaulting to 'public'") - visibility = "public" + print(f"šŸ” Scanning {filter_config['str']} repositories in {org} organization...") - # Security: if calling repo is public, restrict to public repos only - if repo_visibility == "public" and visibility != "public": - print(f"āš ļø Security: Public repo cannot scan {visibility} repos. Restricting to public only.") - visibility = "public" - - print(f"šŸ” Scanning {visibility} repositories in {org} organization...") - - # Get active repos with specified visibility - cmd = ["gh", "repo", "list", org, "--limit", "1000", "--json", "name,url,isArchived"] - if visibility != "all": - cmd.extend(["--visibility", visibility]) - - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - repos = {r["name"]: r["url"] for r in json.loads(result.stdout) if not r["isArchived"]} + # Get active repos + result = subprocess.run( + ["gh", "repo", "list", org, "--limit", "1000", "--json", "name,url,isArchived,visibility"] + + filter_config["flag"], + capture_output=True, + text=True, + check=True, + ) + all_repos = [r for r in json.loads(result.stdout) if not r["isArchived"]] + repos = { + r["name"]: r["url"] + for r in all_repos + if not filter_config["filter"] or r["visibility"].lower() in filter_config["filter"] + } if not repos: print("āš ļø No repositories found") @@ -87,10 +125,8 @@ def run(): # Count PRs by phase phase_counts = {"new": 0, "green": 0, "yellow": 0, "red": 0} for pr in all_prs: - age_days = get_age_days(pr["createdAt"]) - phase_counts[ - "new" if age_days == 0 else "green" if age_days <= 7 else "yellow" if age_days <= 30 else "red" - ] += 1 + age = get_age_days(pr["createdAt"]) + phase_counts["new" if age == 0 else "green" if age <= 7 else "yellow" if age <= 30 else "red"] += 1 repo_count = len({pr["repository"]["name"] for pr in all_prs if pr["repository"]["name"] in repos}) summary = [ @@ -99,10 +135,7 @@ def run(): f"**By Phase:** šŸ†• {phase_counts['new']} New | 🟢 {phase_counts['green']} ≤7d | 🟔 {phase_counts['yellow']} ≤30d | šŸ”“ {phase_counts['red']} >30d\n", ] - for repo_name in sorted({pr["repository"]["name"] for pr in all_prs}): - if repo_name not in repos: - continue - + for repo_name in sorted({pr["repository"]["name"] for pr in all_prs if pr["repository"]["name"] in repos}): repo_prs = [pr for pr in all_prs if pr["repository"]["name"] == repo_name] summary.append( f"## šŸ“¦ [{repo_name}]({repos[repo_name]}) - {len(repo_prs)} open PR{'s' if len(repo_prs) > 1 else ''}" @@ -161,18 +194,8 @@ def run(): total_skipped += 1 continue - # Check if all status checks passed (normalize rollup structure) - rollup = pr.get("statusCheckRollup") - if isinstance(rollup, list): - checks = rollup - elif isinstance(rollup, dict): - checks = rollup.get("contexts", []) - else: - checks = [] - failed_checks = [c for c in checks if c.get("conclusion") not in ["SUCCESS", "SKIPPED", "NEUTRAL"]] - - if failed_checks: - for check in failed_checks: + if failed := get_status_checks(pr.get("statusCheckRollup")): + for check in failed: print(f" āŒ Failing check: {check.get('name', 'unknown')} = {check.get('conclusion')}") total_skipped += 1 continue @@ -195,7 +218,6 @@ def run(): summary.append(f"\n**Summary:** Found {total_found} | Merged {total_merged} | Skipped {total_skipped}") print(f"\nšŸ“Š Dependabot Summary: Found {total_found} | Merged {total_merged} | Skipped {total_skipped}") - # Write to GitHub step summary if available if summary_file := os.getenv("GITHUB_STEP_SUMMARY"): with open(summary_file, "a") as f: f.write("\n".join(summary)) diff --git a/scan-prs/README.md b/scan-prs/README.md index 6a1b1f2..be72cd8 100644 --- a/scan-prs/README.md +++ b/scan-prs/README.md @@ -29,18 +29,18 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} org: ultralytics # Optional: defaults to ultralytics - visibility: public # Optional: public, private, internal, or all + visibility: private,internal # Optional: public, private, internal, all, or comma-separated ``` ## Inputs -| Input | Description | Required | Default | -| ------------ | ------------------------------------------------------------------------ | -------- | ------------- | -| `token` | GitHub token with admin permissions for merging | Yes | - | -| `org` | GitHub organization name | No | `ultralytics` | -| `visibility` | Repository visibility to scan: `public`, `private`, `internal`, or `all` | No | `public` | +| Input | Description | Required | Default | +| ------------ | ------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- | +| `token` | GitHub token with admin permissions for merging | Yes | - | +| `org` | GitHub organization name | No | `ultralytics` | +| `visibility` | Repository visibility to scan: `public`, `private`, `internal`, `all`, or comma-separated list (e.g., `private,internal`) | No | `public` | -**Security Note:** If the calling repository is public, scanning is automatically restricted to public repos only, even if a different visibility is specified. +**Security Note:** If the calling repository is public, scanning is automatically restricted to public repos only, even if non-public visibilities are specified. ## Auto-merge Criteria diff --git a/scan-prs/action.yml b/scan-prs/action.yml index 95ef0f1..c189e3a 100644 --- a/scan-prs/action.yml +++ b/scan-prs/action.yml @@ -15,7 +15,7 @@ inputs: required: false default: "ultralytics" visibility: - description: "Repository visibility to scan: public, private, internal, or all" + description: "Repository visibility to scan: public, private, internal, all, or comma-separated (e.g., 'private,internal')" required: false default: "public" runs: