diff --git a/.github/workflows/github_report.yml b/.github/workflows/github_report.yml index 1cfe641..3097a87 100644 --- a/.github/workflows/github_report.yml +++ b/.github/workflows/github_report.yml @@ -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 diff --git a/README.md b/README.md index 2b5e132..a89ff68 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/README.zh-CN.md b/README.zh-CN.md index da30483..fa436ff 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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) diff --git a/actions/github_report.py b/actions/github_report.py index 89f3ee6..f05ea38 100644 --- a/actions/github_report.py +++ b/actions/github_report.py @@ -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() diff --git a/github-report/README.md b/github-report/README.md index 2b90a12..79c6aef 100644 --- a/github-report/README.md +++ b/github-report/README.md @@ -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 diff --git a/github-report/action.yml b/github-report/action.yml index 7fe94cb..1bcbc5c 100644 --- a/github-report/action.yml +++ b/github-report/action.yml @@ -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: | diff --git a/tests/test_github_report.py b/tests/test_github_report.py index 3592249..d960ada 100644 --- a/tests/test_github_report.py +++ b/tests/test_github_report.py @@ -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"