├── README.md └── main.py /README.md: -------------------------------------------------------------------------------- 1 | ## GitHub PR status validator 2 | Detects force-merged pull requests by checking both status checks and check runs on the merge and last commits, ensuring all required checks passed before merging. 3 | 4 | Example of how to use the script: 5 | 6 | Step 1: Set Environment Variables 7 | 8 | ``` 9 | export GITHUB_TOKEN="your_github_token" 10 | export GITHUB_OWNER="your_org_or_username" 11 | export GITHUB_REPO="your_repository_name" 12 | ``` 13 | 14 | Step 2: Run the Script 15 | Once the environment variables are set, run the script in your Python environment: 16 | ``` 17 | python main.py 18 | ``` 19 | 20 | Expected Output 21 | The script will output a list of PRs that were force-merged without meeting all checks. For example: 22 | ``` 23 | 2024-11-05 23:42:48,221 - INFO - GitHub token and repository details retrieved from environment variables. 24 | 2024-11-05 23:42:48,222 - INFO - Searching for force merged PRs in repository: your_org_or_username/your_repository_name 25 | 2024-11-05 23:42:49,511 - INFO - Checking PR #18: go: bump github.com/onsi/gomega from 1.35.0 to 1.35.1 26 | 2024-11-05 23:42:49,511 - INFO - PR #18 is merged. Checking commit statuses and check runs. 27 | 2024-11-05 23:42:50,526 - INFO - No statuses found for commit 01cb75ca997bfbca9c0b43f10d70d1280d96f1c8. 28 | 2024-11-05 23:42:51,107 - INFO - Found 6 check runs for commit 01cb75ca997bfbca9c0b43f10d70d1280d96f1c8 29 | 2024-11-05 23:42:51,107 - INFO - Check Run: Dependabot, Status: completed, Conclusion: success 30 | 2024-11-05 23:42:51,107 - INFO - Check Run: Dependabot, Status: completed, Conclusion: success 31 | 2024-11-05 23:42:51,108 - INFO - Check Run: Dependabot, Status: completed, Conclusion: success 32 | 2024-11-05 23:42:51,108 - INFO - Check Run: Dependabot, Status: completed, Conclusion: success 33 | 2024-11-05 23:42:51,108 - INFO - Check Run: Check for spelling errors, Status: completed, Conclusion: failure 34 | 2024-11-05 23:42:51,108 - INFO - Check Run: lint, Status: completed, Conclusion: failure 35 | 2024-11-05 23:42:51,108 - WARNING - PR #18 was force merged without all checks passing. 36 | 2024-11-05 23:43:14,602 - INFO - Completed search for force merged PRs. 37 | 2024-11-05 23:43:14,602 - INFO - Force Merged PRs: 38 | 2024-11-05 23:43:14,602 - INFO - - PR #18: go: bump github.com/onsi/gomega from 1.35.0 to 1.35.1 (merged at 2024-11-03 18:10:02+00:00) 39 | ``` 40 | 41 | Explanation 42 | The script: 43 | 44 | 1. Checks each closed PR in the specified repository. 45 | 2. Outputs warnings for any PRs that were force-merged without passing all required checks, along with PR details. 46 | 47 | This makes it easy to audit any PRs merged without meeting all requirements. 48 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from github import Github 4 | 5 | # Configure logging 6 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 7 | logger = logging.getLogger() 8 | 9 | # Read GitHub token, organization, and repository name from env varibles 10 | token = os.getenv("GITHUB_TOKEN") 11 | owner = os.getenv("GITHUB_OWNER") 12 | repo_name = os.getenv("GITHUB_REPO") 13 | 14 | logger.info("GitHub token and repository details retrieved from environment variables.") 15 | 16 | # Authenticate 17 | g = Github(token) 18 | 19 | def find_force_merged_prs(owner, repo_name): 20 | logger.info(f"Searching for force merged PRs in repository: {owner}/{repo_name}") 21 | repo = g.get_repo(f"{owner}/{repo_name}") 22 | closed_prs = repo.get_pulls(state="closed") 23 | force_merged_prs = [] 24 | 25 | for pr in closed_prs: 26 | logger.info(f"Checking PR #{pr.number}: {pr.title}") 27 | 28 | if pr.merged_at: 29 | logger.info(f"PR #{pr.number} is merged. Checking commit statuses and check runs.") 30 | 31 | # Check both the merge commit and the last commit on the PR branch 32 | merge_commit_sha = pr.merge_commit_sha 33 | head_commit_sha = pr.head.sha 34 | 35 | # Function to check commit statuses and check runs for a given commit SHA 36 | def check_commit_status_and_runs(commit_sha): 37 | commit = repo.get_commit(commit_sha) 38 | 39 | # Check Commit Statuses 40 | combined_status = commit.get_combined_status() 41 | statuses = combined_status.statuses 42 | has_failed_statuses = any( 43 | status.state in ["failure", "pending"] for status in statuses 44 | ) 45 | 46 | # Log statuses 47 | if statuses: 48 | logger.info(f"Found {len(statuses)} statuses for commit {commit_sha}") 49 | for status in statuses: 50 | logger.info(f"Status Context: {status.context}, State: {status.state}") 51 | else: 52 | logger.info(f"No statuses found for commit {commit_sha}.") 53 | 54 | # Check Check Runs (GitHub Actions or other Checks API integrations) 55 | check_runs = commit.get_check_runs() 56 | has_failed_runs = any( 57 | run.conclusion in ["failure", "cancelled", "timed_out"] or run.status == "in_progress" 58 | for run in check_runs 59 | ) 60 | 61 | # Log check runs 62 | if check_runs.totalCount > 0: 63 | logger.info(f"Found {check_runs.totalCount} check runs for commit {commit_sha}") 64 | for run in check_runs: 65 | logger.info(f"Check Run: {run.name}, Status: {run.status}, Conclusion: {run.conclusion}") 66 | else: 67 | logger.info(f"No check runs found for commit {commit_sha}.") 68 | 69 | # true if either statuses or check runs have failed or are pending 70 | return has_failed_statuses or has_failed_runs 71 | 72 | # Check the merge commit first, then fall back to the PR's head commit 73 | has_failed_or_pending_checks = check_commit_status_and_runs(merge_commit_sha) 74 | if not has_failed_or_pending_checks: 75 | logger.info(f"Falling back to checking last commit on PR branch for PR #{pr.number}.") 76 | has_failed_or_pending_checks = check_commit_status_and_runs(head_commit_sha) 77 | 78 | # Record the PR if there are failed or pending statuses or check runs 79 | if has_failed_or_pending_checks: 80 | logger.warning(f"PR #{pr.number} was force merged without all checks passing.") 81 | force_merged_prs.append(pr) 82 | else: 83 | logger.info(f"PR #{pr.number} passed all required checks before merging.") 84 | else: 85 | logger.info(f"PR #{pr.number} was closed but not merged.") 86 | 87 | logger.info("Completed search for force merged PRs.") 88 | return force_merged_prs 89 | 90 | force_merged_prs = find_force_merged_prs(owner, repo_name) 91 | logger.info("Force Merged PRs:") 92 | for pr in force_merged_prs: 93 | logger.info(f"- PR #{pr.number}: {pr.title} (merged at {pr.merged_at})") 94 | 95 | --------------------------------------------------------------------------------