Restore GitHub report PR scanning (#783)

This commit is contained in:
Glenn Jocher
2026-07-01 22:19:35 +02:00
committed by GitHub
parent cec6eff04f
commit 101c9e3407
7 changed files with 472 additions and 13 deletions
+3 -1
View File
@@ -16,8 +16,9 @@ on:
- cron: "0 6 * * *" # daily at 06:00 UTC
permissions:
contents: read
contents: write
actions: read
pull-requests: write
jobs:
github-report:
@@ -28,3 +29,4 @@ jobs:
token: ${{ secrets._GITHUB_TOKEN }}
visibility: public
days: ${{ inputs.days || 1 }}
auto_merge_actions_prs: true
+2 -1
View File
@@ -146,7 +146,7 @@ Free up disk space on GitHub runners by removing unnecessary packages and files.
### 3. GitHub Report Action
Summarize failed default-branch GitHub Actions across organization repositories.
Summarize open PRs and failed default-branch GitHub Actions across organization repositories.
```yaml
- uses: ultralytics/actions/github-report@main
@@ -154,6 +154,7 @@ Summarize failed default-branch GitHub Actions across organization repositories.
token: ${{ secrets.GITHUB_TOKEN }}
org: ultralytics # Optional: defaults to ultralytics
visibility: all # Optional: public, private, internal, all, or comma-separated
auto_merge_actions_prs: true # Optional: auto-merge eligible GitHub Actions update PRs
```
[**📖 Full Documentation →**](github-report/README.md)
+2 -1
View File
@@ -146,7 +146,7 @@ jobs:
### 3. GitHub Report Action
汇总组织仓库默认分支上失败的 GitHub Actions。
汇总组织仓库中的 open PR 以及默认分支上失败的 GitHub Actions。
```yaml
- uses: ultralytics/actions/github-report@main
@@ -154,6 +154,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
org: ultralytics # Optional: defaults to ultralytics
visibility: all # Optional: public, private, internal, all, or comma-separated
auto_merge_actions_prs: true # Optional: auto-merge eligible GitHub Actions update PRs
```
[**📖 完整文档 →**](github-report/README.md)
+253 -2
View File
@@ -1,8 +1,15 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
"""Generate GitHub organization reports."""
import json
import os
import subprocess
from datetime import datetime, timezone
from actions import failed_scheduled_actions
PASSING_CHECK_STATES = {"SUCCESS"}
def enabled(value):
"""Return whether a string environment-style value is enabled."""
@@ -14,10 +21,254 @@ def enabled_any(*values, default="true"):
return enabled(next((value for value in values if value not in {None, ""}), default))
def get_age_days(created_at):
"""Return the age in whole days from an ISO timestamp."""
return (datetime.now(timezone.utc) - datetime.fromisoformat(created_at.replace("Z", "+00:00"))).days
def get_phase_emoji(age_days):
"""Return the age phase emoji and label."""
if age_days == 0:
return "🆕", "NEW"
if age_days <= 7:
return "🟢", f"{age_days} days"
if age_days <= 30:
return "🟡", f"{age_days} days"
return "🔴", f"{age_days} days"
def get_status_checks(rollup):
"""Return status checks from a GitHub statusCheckRollup value."""
return rollup if isinstance(rollup, list) else rollup.get("contexts", []) if isinstance(rollup, dict) else []
def get_unpassed_status_checks(rollup):
"""Return status checks that are not complete and passing."""
checks = get_status_checks(rollup)
return [
check
for check in checks
if (check.get("conclusion") or check.get("state") or "").upper() not in PASSING_CHECK_STATES
]
def gh_json(args, check=True):
"""Run a GitHub CLI command and return parsed JSON output."""
result = subprocess.run(["gh", *args], capture_output=True, text=True, check=check)
return json.loads(result.stdout or "[]")
def collect_repos(org, visibility="public", repo_visibility="public"):
"""Collect non-archived repositories matching the requested visibility."""
visibility_list = failed_scheduled_actions.parse_visibility(visibility, repo_visibility)
filter_config = failed_scheduled_actions.get_repo_filter(visibility_list)
repos = gh_json(["repo", "list", org, "--limit", "1000", "--json", "name,url,isArchived,visibility"])
if len(visibility_list) == 1 and visibility_list[0] != "all":
allowed = {visibility_list[0]}
else:
allowed = filter_config["filter"]
return {
repo["name"]: repo["url"]
for repo in repos
if not repo["isArchived"] and (not allowed or repo["visibility"].lower() in allowed)
}, filter_config["str"]
def collect_open_prs(org, repos):
"""Collect open pull requests from the selected repositories."""
prs = gh_json(
[
"search",
"prs",
"--owner",
org,
"--state",
"open",
"--limit",
"1000",
"--json",
"repository,number,title,url,createdAt",
"--sort",
"created",
"--order",
"desc",
]
)
return [pr for pr in prs if pr["repository"]["name"] in repos]
def format_pr_report(prs, repos, visibility, org="ultralytics"):
"""Format open pull requests as Markdown."""
lines = [f"# 🔍 Open Pull Requests - {org.title()} Organization", ""]
if not prs:
lines.append("No open PRs found in scanned repositories.")
return "\n".join(lines) + "\n"
phase_counts = {"new": 0, "green": 0, "yellow": 0, "red": 0}
for pr in prs:
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 prs})
lines.extend(
[
f"**Total:** {len(prs)} open PRs across {repo_count}/{len(repos)} {visibility} repos",
f"**By Phase:** 🆕 {phase_counts['new']} New | 🟢 {phase_counts['green']} ≤7d | "
f"🟡 {phase_counts['yellow']} ≤30d | 🔴 {phase_counts['red']} >30d",
"",
]
)
for repo_name in sorted({pr["repository"]["name"] for pr in prs}):
repo_prs = [pr for pr in prs if pr["repository"]["name"] == repo_name]
detail = f"{len(repo_prs)} open PR{'s' if len(repo_prs) > 1 else ''}"
lines.append(f"{failed_scheduled_actions.format_repo_heading(repo_name, repos[repo_name])} - {detail}")
for pr in repo_prs[:30]:
emoji, age = get_phase_emoji(get_age_days(pr["createdAt"]))
lines.append(f"- [#{pr['number']}]({pr['url']}) {pr['title']} {emoji} {age}")
if len(repo_prs) > 30:
lines.append(f"- ... {len(repo_prs) - 30} more PRs")
lines.append("")
return "\n".join(lines) + "\n"
def auto_merge_actions_prs(org, repos):
"""Auto-merge eligible GitHub Actions update pull requests."""
print("\n🤖 Checking for GitHub Actions update PRs to auto-merge...")
lines = ["# 🤖 Auto-Merge GitHub Actions Update PRs", ""]
total_found = total_merged = total_skipped = 0
approved_authors = ("app/dependabot", "UltralyticsAssistant")
for repo_name in repos:
prs = []
for author in approved_authors:
result = subprocess.run(
[
"gh",
"pr",
"list",
"--repo",
f"{org}/{repo_name}",
"--author",
author,
"--state",
"open",
"--json",
"number,title,url,files,mergeable,statusCheckRollup",
],
capture_output=True,
text=True,
)
if result.returncode == 0:
prs.extend(json.loads(result.stdout or "[]"))
merged = 0
for pr in prs:
title = pr.get("title", "").lower()
if "bump" not in title or "/.github/workflows" not in title:
continue
total_found += 1
pr_ref = f"{org}/{repo_name}#{pr['number']}"
paths = [file["path"] for file in (pr.get("files") or [])]
if not paths or any(
not (path.startswith(".github/workflows/") or path.endswith(("action.yml", "action.yaml")))
for path in paths
):
print(" ⏭️ Skipped (mixed or non-action files)")
lines.append(f"- ❌ {pr_ref}: mixed or non-action files")
total_skipped += 1
continue
pr_url = pr.get("url") or f"https://github.com/{org}/{repo_name}/pull/{pr['number']}"
print(f" Found: {pr_url} - {pr['title']}")
checks = get_status_checks(pr.get("statusCheckRollup"))
if checks:
print(" ℹ️ Status checks:")
for check in checks:
name = check.get("name") or check.get("context") or "unknown"
status = check.get("conclusion") or check.get("state") or ""
print(f" - {name}: {status}")
else:
print(" ❌ Skipped (no status checks found)")
lines.append(f"- ❌ {pr_ref}: no status checks found")
total_skipped += 1
continue
if merged >= 1:
print(f" ⏭️ Skipped (already merged 1 PR in {repo_name})")
total_skipped += 1
continue
mergeable = pr.get("mergeable", "UNKNOWN")
if mergeable == "CONFLICTING":
print(" ❌ Skipped (merge conflicts)")
lines.append(f"- ❌ {pr_ref}: merge conflicts")
total_skipped += 1
continue
if unpassed := get_unpassed_status_checks(pr.get("statusCheckRollup")):
names = ", ".join(check.get("name") or check.get("context") or "?" for check in unpassed)
print(f" ❌ Skipped (checks not passing: {names})")
lines.append(f"- ❌ {pr_ref}: checks not passing ({names})")
total_skipped += 1
continue
print(f" 🔀 Merging (mergeable={mergeable})...")
result = subprocess.run(
[
"gh",
"pr",
"merge",
str(pr["number"]),
"--repo",
f"{org}/{repo_name}",
"--squash",
"--admin",
"--delete-branch",
],
capture_output=True,
text=True,
)
if result.returncode == 0:
print(f" ✅ Merged {pr_ref}")
lines.append(f"- ✅ Merged {pr_ref}")
total_merged += 1
merged += 1
else:
error = result.stderr.strip()
print(f" ❌ Merge failed: {error}")
lines.append(f"- ❌ {pr_ref}: {error[:80]}")
total_skipped += 1
lines.append(f"\n**Summary:** Found {total_found} | Merged {total_merged} | Skipped {total_skipped}")
print(f"\n📊 Auto-Merge Summary: Found {total_found} | Merged {total_merged} | Skipped {total_skipped}")
return "\n".join(lines) + "\n"
def run_pr_report():
"""Write open PR and optional auto-merge reports to stdout and the GitHub step summary."""
org = os.getenv("ORG", "ultralytics")
repos, visibility = collect_repos(org, os.getenv("VISIBILITY", "public"), os.getenv("REPO_VISIBILITY", "public"))
print(f"🔍 Scanning {visibility} repositories in {org} organization...")
report = format_pr_report(collect_open_prs(org, repos) if repos else [], repos, visibility, org)
if enabled(os.getenv("AUTO_MERGE_ACTIONS_PRS", "true")):
report += "\n" + auto_merge_actions_prs(org, repos)
else:
report += "\n# 🤖 Auto-Merge GitHub Actions Update PRs\n\nAuto-merge disabled for this run.\n"
print(report)
if summary_file := os.getenv("GITHUB_STEP_SUMMARY"):
with open(summary_file, "a") as f:
f.write(report)
def run():
"""Run enabled GitHub report sections."""
import os
if enabled(os.getenv("REPORT_PRS", "true")):
run_pr_report()
if enabled_any(os.getenv("REPORT_FAILED_ACTIONS"), os.getenv("REPORT_FAILED_SCHEDULED_ACTIONS")):
failed_scheduled_actions.run()
+8 -2
View File
@@ -1,6 +1,6 @@
# GitHub Report Action
Summarize failed default-branch GitHub Actions across organization repositories.
Summarize open PRs and failed default-branch GitHub Actions across organization repositories.
## Usage
@@ -12,8 +12,9 @@ on:
- cron: "0 6 * * *"
permissions:
contents: read
contents: write
actions: read
pull-requests: write
jobs:
github-report:
@@ -25,6 +26,7 @@ jobs:
org: ultralytics
visibility: private,internal
days: 1
auto_merge_actions_prs: true
```
## Inputs
@@ -34,8 +36,10 @@ jobs:
| `token` | GitHub token with access to organization repositories | Yes | - |
| `org` | GitHub organization name | No | `ultralytics` |
| `visibility` | Repository visibility: `public`, `private`, `internal`, `all` | No | `public` |
| `prs` | Include open PR summary | No | `true` |
| `failed_actions` | Include failed default-branch Actions summary | No | `true` |
| `failed_scheduled_actions` | Deprecated alias for `failed_actions` | No | - |
| `auto_merge_actions_prs` | Auto-merge eligible GitHub Actions update PRs | No | `true` |
| `max_run_pages` | Pages of workflow runs to inspect per repository | No | `3` |
| `days` | Days of workflow history to include | No | `1` |
@@ -44,3 +48,5 @@ jobs:
The action writes a GitHub step summary with:
- Latest failed workflow runs on each repository's default branch, grouped by event
- Open PRs by repository with age-based status
- Eligible GitHub Actions update PRs merged or skipped with reasons
+12 -2
View File
@@ -2,13 +2,13 @@
name: "GitHub Report"
author: "Ultralytics"
description: "Summarize failed default-branch GitHub Actions across organization repositories"
description: "Summarize open PRs and failed default-branch GitHub Actions across organization repositories"
branding:
icon: "activity"
color: "blue"
inputs:
token:
description: "GitHub token with access to organization repositories"
description: "GitHub token with access to scanned repositories"
required: true
org:
description: "GitHub organization name"
@@ -18,6 +18,10 @@ inputs:
description: "Repository visibility: public, private, internal, all, or comma-separated"
required: false
default: "public"
prs:
description: "Include open PR summary"
required: false
default: "true"
failed_actions:
description: "Include failed default-branch Actions summary"
required: false
@@ -26,6 +30,10 @@ inputs:
description: "Deprecated alias for failed_actions"
required: false
default: ""
auto_merge_actions_prs:
description: "Auto-merge eligible GitHub Actions update PRs in the PR section"
required: false
default: "true"
max_run_pages:
description: "Pages of workflow runs to inspect per repository"
required: false
@@ -69,8 +77,10 @@ runs:
ORG: ${{ inputs.org }}
VISIBILITY: ${{ inputs.visibility }}
REPO_VISIBILITY: ${{ github.event.repository.visibility }}
REPORT_PRS: ${{ inputs.prs }}
REPORT_FAILED_ACTIONS: ${{ inputs.failed_actions }}
REPORT_FAILED_SCHEDULED_ACTIONS: ${{ inputs.failed_scheduled_actions }}
AUTO_MERGE_ACTIONS_PRS: ${{ inputs.auto_merge_actions_prs }}
MAX_RUN_PAGES: ${{ inputs.max_run_pages }}
REPORT_DAYS: ${{ inputs.days }}
run: |
+192 -4
View File
@@ -79,7 +79,7 @@ def test_collect_failed_actions_latest_run_per_workflow(monkeypatch):
monkeypatch.setattr(failed_scheduled_actions, "paginate", fake_paginate)
failures = failed_scheduled_actions.collect_failed_actions(
visibility="all", repo_visibility="private", token="token"
visibility="all", repo_visibility="private", days=3, token="token"
)
assert len(failures) == 1
@@ -185,20 +185,80 @@ def test_format_report_links_failures():
assert "[Run #7](https://github.com/ultralytics/private-repo/actions/runs/7)" in report
def test_github_report_runs_failed_actions_section(monkeypatch):
"""The shared report driver runs the failed Actions section by default."""
def test_format_pr_report_lists_open_prs(monkeypatch):
"""The PR report includes age phases and short linked repository headings."""
monkeypatch.setattr(github_report, "get_age_days", lambda created_at: 8)
report = github_report.format_pr_report(
[
{
"repository": {"name": "private-repo"},
"number": 9,
"title": "Bump actions/cache in /.github/workflows/ci.yml",
"url": "https://github.com/ultralytics/private-repo/pull/9",
"createdAt": "2026-06-30T01:02:03Z",
}
],
{"private-repo": "https://github.com/ultralytics/private-repo"},
"all",
)
assert "# 🔍 Open Pull Requests - Ultralytics Organization" in report
assert "**Total:** 1 open PRs across 1/1 all repos" in report
assert "**By Phase:** 🆕 0 New | 🟢 0 ≤7d | 🟡 1 ≤30d | 🔴 0 >30d" in report
assert "## 📦 [private-repo](https://github.com/ultralytics/private-repo) - 1 open PR" in report
assert "[#9](https://github.com/ultralytics/private-repo/pull/9)" in report
def test_collect_repos_filters_single_visibility(monkeypatch):
"""A single visibility input should not include other accessible repository types."""
monkeypatch.setattr(
github_report,
"gh_json",
lambda args: [
{
"name": "public-repo",
"url": "https://github.com/ultralytics/public-repo",
"visibility": "public",
"isArchived": False,
},
{
"name": "private-repo",
"url": "https://github.com/ultralytics/private-repo",
"visibility": "private",
"isArchived": False,
},
{
"name": "archived-repo",
"url": "https://github.com/ultralytics/archived-repo",
"visibility": "public",
"isArchived": True,
},
],
)
repos, visibility = github_report.collect_repos("ultralytics", "public", "public")
assert visibility == "public"
assert repos == {"public-repo": "https://github.com/ultralytics/public-repo"}
def test_github_report_runs_enabled_sections(monkeypatch):
"""The shared report driver runs PR and failed Actions sections by default."""
calls = []
monkeypatch.setattr(github_report, "run_pr_report", lambda: calls.append("prs"))
monkeypatch.setattr(github_report.failed_scheduled_actions, "run", lambda: calls.append("actions"))
github_report.run()
assert calls == ["actions"]
assert calls == ["prs", "actions"]
def test_github_report_keeps_failed_scheduled_actions_alias(monkeypatch):
"""The old failed_scheduled_actions input remains a compatibility alias."""
calls = []
monkeypatch.setenv("REPORT_PRS", "false")
monkeypatch.setenv("REPORT_FAILED_SCHEDULED_ACTIONS", "false")
monkeypatch.setattr(github_report, "run_pr_report", lambda: calls.append("prs"))
monkeypatch.setattr(github_report.failed_scheduled_actions, "run", lambda: calls.append("actions"))
github_report.run()
@@ -206,6 +266,134 @@ def test_github_report_keeps_failed_scheduled_actions_alias(monkeypatch):
assert calls == []
def test_auto_merge_actions_prs_merges_eligible_update(monkeypatch):
"""Eligible GitHub Actions update PRs are merged when checks pass."""
commands = []
def fake_run(cmd, capture_output=True, text=True, check=False):
commands.append(cmd)
if cmd[:4] == ["gh", "pr", "list", "--repo"]:
if "app/dependabot" not in cmd:
return type("Result", (), {"returncode": 0, "stdout": "[]", "stderr": ""})
return type(
"Result",
(),
{
"returncode": 0,
"stdout": (
'[{"number": 9, "title": "Bump actions/cache in /.github/workflows/ci.yml", '
'"url": "https://github.com/ultralytics/repo/pull/9", '
'"files": [{"path": ".github/workflows/ci.yml"}], '
'"mergeable": "MERGEABLE", '
'"statusCheckRollup": [{"name": "CI", "conclusion": "SUCCESS"}]}]'
),
"stderr": "",
},
)
if cmd[:3] == ["gh", "pr", "merge"]:
return type("Result", (), {"returncode": 0, "stdout": "", "stderr": ""})
raise AssertionError(cmd)
monkeypatch.setattr(github_report.subprocess, "run", fake_run)
report = github_report.auto_merge_actions_prs("ultralytics", {"repo": "https://github.com/ultralytics/repo"})
assert "- ✅ Merged ultralytics/repo#9" in report
assert "**Summary:** Found 1 | Merged 1 | Skipped 0" in report
assert any(command[:3] == ["gh", "pr", "merge"] for command in commands)
def test_auto_merge_actions_prs_skips_without_passing_checks(monkeypatch):
"""Empty, pending, skipped, or neutral status checks should not be auto-merged."""
commands = []
def fake_run(cmd, capture_output=True, text=True, check=False):
commands.append(cmd)
if cmd[:4] == ["gh", "pr", "list", "--repo"]:
if "app/dependabot" not in cmd:
return type("Result", (), {"returncode": 0, "stdout": "[]", "stderr": ""})
return type(
"Result",
(),
{
"returncode": 0,
"stdout": (
'[{"number": 9, "title": "Bump actions/cache in /.github/workflows/ci.yml", '
'"url": "https://github.com/ultralytics/repo/pull/9", '
'"files": [{"path": ".github/workflows/ci.yml"}], '
'"mergeable": "MERGEABLE", "statusCheckRollup": []}, '
'{"number": 10, "title": "Bump actions/cache in /.github/workflows/ci.yml", '
'"url": "https://github.com/ultralytics/repo/pull/10", '
'"files": [{"path": ".github/workflows/ci.yml"}], '
'"mergeable": "MERGEABLE", '
'"statusCheckRollup": [{"name": "CI", "conclusion": null, "state": "PENDING"}]}, '
'{"number": 11, "title": "Bump actions/cache in /.github/workflows/ci.yml", '
'"url": "https://github.com/ultralytics/repo/pull/11", '
'"files": [{"path": ".github/workflows/ci.yml"}], '
'"mergeable": "MERGEABLE", '
'"statusCheckRollup": [{"name": "CI", "conclusion": "SKIPPED"}]}, '
'{"number": 12, "title": "Bump actions/cache in /.github/workflows/ci.yml", '
'"url": "https://github.com/ultralytics/repo/pull/12", '
'"files": [{"path": ".github/workflows/ci.yml"}], '
'"mergeable": "MERGEABLE", '
'"statusCheckRollup": [{"name": "CI", "conclusion": "NEUTRAL"}]}]'
),
"stderr": "",
},
)
if cmd[:3] == ["gh", "pr", "merge"]:
raise AssertionError("Unexpected merge")
raise AssertionError(cmd)
monkeypatch.setattr(github_report.subprocess, "run", fake_run)
report = github_report.auto_merge_actions_prs("ultralytics", {"repo": "https://github.com/ultralytics/repo"})
assert "- ❌ ultralytics/repo#9: no status checks found" in report
assert "- ❌ ultralytics/repo#10: checks not passing (CI)" in report
assert "- ❌ ultralytics/repo#11: checks not passing (CI)" in report
assert "- ❌ ultralytics/repo#12: checks not passing (CI)" in report
assert "**Summary:** Found 4 | Merged 0 | Skipped 4" in report
assert not any(command[:3] == ["gh", "pr", "merge"] for command in commands)
def test_auto_merge_actions_prs_skips_mixed_files(monkeypatch):
"""GitHub Actions update PRs with unrelated files should not be auto-merged."""
commands = []
def fake_run(cmd, capture_output=True, text=True, check=False):
commands.append(cmd)
if cmd[:4] == ["gh", "pr", "list", "--repo"]:
if "app/dependabot" not in cmd:
return type("Result", (), {"returncode": 0, "stdout": "[]", "stderr": ""})
return type(
"Result",
(),
{
"returncode": 0,
"stdout": (
'[{"number": 9, "title": "Bump actions/cache in /.github/workflows/ci.yml", '
'"url": "https://github.com/ultralytics/repo/pull/9", '
'"files": [{"path": ".github/workflows/ci.yml"}, {"path": "actions/github_report.py"}], '
'"mergeable": "MERGEABLE", '
'"statusCheckRollup": [{"name": "CI", "conclusion": "SUCCESS"}]}]'
),
"stderr": "",
},
)
if cmd[:3] == ["gh", "pr", "merge"]:
raise AssertionError("Unexpected merge")
raise AssertionError(cmd)
monkeypatch.setattr(github_report.subprocess, "run", fake_run)
report = github_report.auto_merge_actions_prs("ultralytics", {"repo": "https://github.com/ultralytics/repo"})
assert "- ❌ ultralytics/repo#9: mixed or non-action files" in report
assert "**Summary:** Found 1 | Merged 0 | Skipped 1" in report
assert not any(command[:3] == ["gh", "pr", "merge"] for command in commands)
def test_failed_scheduled_actions_summary_appends_section_break(tmp_path, monkeypatch):
"""Appending after existing summary content should not concatenate Markdown headings."""
summary = tmp_path / "summary.md"