Restore GitHub report PR scanning (#783)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user