approved PR gate

This commit is contained in:
Mitchell Hashimoto
2026-02-03 09:18:24 -08:00
parent dce6552801
commit 39e610d0ee
4 changed files with 185 additions and 34 deletions

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env nu
use github.nu
# Approve a contributor by adding them to the APPROVED_CONTRIBUTORS file.
#
# This script checks if a comment matches "lgtm", verifies the commenter has
@@ -31,8 +33,8 @@ def main [
let repo_name = ($repo | split row "/" | last)
# Fetch issue and comment data from GitHub API
let issue_data = github-api "get" $"/repos/($owner)/($repo_name)/issues/($issue_id)"
let comment_data = github-api "get" $"/repos/($owner)/($repo_name)/issues/comments/($comment_id)"
let issue_data = github api "get" $"/repos/($owner)/($repo_name)/issues/($issue_id)"
let comment_data = github api "get" $"/repos/($owner)/($repo_name)/issues/comments/($comment_id)"
let issue_author = $issue_data.user.login
let commenter = $comment_data.user.login
@@ -47,7 +49,7 @@ def main [
# Check if commenter has write access
let permission = try {
github-api "get" $"/repos/($owner)/($repo_name)/collaborators/($commenter)/permission" | get permission
github api "get" $"/repos/($owner)/($repo_name)/collaborators/($commenter)/permission" | get permission
} catch {
print $"($commenter) does not have collaborator access"
print "skipped"
@@ -72,7 +74,7 @@ def main [
print $"($issue_author) is already approved"
if not $dry_run {
github-api "post" $"/repos/($owner)/($repo_name)/issues/($issue_id)/comments" {
github api "post" $"/repos/($owner)/($repo_name)/issues/($issue_id)/comments" {
body: $"@($issue_author) is already in the approved contributors list."
}
} else {
@@ -102,33 +104,3 @@ def main [
print $"Added ($issue_author) to approved contributors"
print "added"
}
# Make a GitHub API request with proper headers
def github-api [
method: string, # HTTP method (get, post, etc.)
endpoint: string # API endpoint (e.g., /repos/owner/repo/issues/1/comments)
body?: record # Optional request body
] {
let url = $"https://api.github.com($endpoint)"
let headers = [
Authorization $"Bearer (get-github-token)"
Accept "application/vnd.github+json"
X-GitHub-Api-Version "2022-11-28"
]
match $method {
"get" => { http get $url --headers $headers },
"post" => { http post $url --headers $headers $body },
_ => { error make { msg: $"Unsupported HTTP method: ($method)" } }
}
}
# Get GitHub token from environment or gh CLI (cached in env)
def get-github-token [] {
if ($env.GITHUB_TOKEN? | is-not-empty) {
return $env.GITHUB_TOKEN
}
$env.GITHUB_TOKEN = (gh auth token | str trim)
$env.GITHUB_TOKEN
}

112
.github/scripts/approved-gate.nu vendored Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env nu
use github.nu
# Approved contributor gate commands.
#
# Environment variables required:
# GITHUB_TOKEN - GitHub API token with repo access. If this isn't
# set then we'll attempt to read from `gh` if it exists.
def main [] {
print "Usage: approved-gate <command>"
print ""
print "Commands:"
print " pr Check if a PR author is an approved contributor"
}
# Check if a PR author is an approved contributor.
#
# Checks if a PR author is a bot, collaborator with write access,
# or in the approved contributors list. If not approved, it closes the PR
# with a comment explaining the process.
#
# Outputs a status to stdout: "skipped", "approved", or "closed"
#
# Examples:
#
# # Dry run (default) - see what would happen
# ./approved-gate.nu pr 123
#
# # Actually close an unapproved PR
# ./approved-gate.nu pr 123 --dry-run=false
#
def "main pr" [
pr_number: int, # GitHub pull request number
--repo (-R): string = "ghostty-org/ghostty", # Repository in "owner/repo" format
--approved-file: string = ".github/APPROVED_CONTRIBUTORS", # Path to approved contributors file
--dry-run = true, # Print what would happen without making changes
] {
let owner = ($repo | split row "/" | first)
let repo_name = ($repo | split row "/" | last)
# Fetch PR data from GitHub API
let pr_data = github api "get" $"/repos/($owner)/($repo_name)/pulls/($pr_number)"
let pr_author = $pr_data.user.login
let default_branch = $pr_data.base.repo.default_branch
# Skip bots
if ($pr_author | str ends-with "[bot]") or ($pr_author == "dependabot[bot]") {
print $"Skipping bot: ($pr_author)"
print "skipped"
return
}
# Check if user is a collaborator with write access
let permission = try {
github api "get" $"/repos/($owner)/($repo_name)/collaborators/($pr_author)/permission" | get permission
} catch {
""
}
if ($permission in ["admin", "write"]) {
print $"($pr_author) is a collaborator with ($permission) access"
print "approved"
return
}
# Fetch approved contributors list from default branch
let file_data = github api "get" $"/repos/($owner)/($repo_name)/contents/($approved_file)?ref=($default_branch)"
let content = $file_data.content | decode base64 | decode utf-8
let approved_list = $content
| lines
| each { |line| $line | str trim | str downcase }
| where { |line| ($line | is-not-empty) and (not ($line | str starts-with "#")) }
if ($pr_author | str downcase) in $approved_list {
print $"($pr_author) is in the approved contributors list"
print "approved"
return
}
# Not approved - close PR with comment
print $"($pr_author) is not approved, closing PR"
let message = $"Hi @($pr_author), thanks for your interest in contributing!
We ask new contributors to open an issue first before submitting a PR. This helps us discuss the approach and avoid wasted effort.
**Next steps:**
1. Open an issue describing what you want to change and why \(keep it concise, write in your human voice, AI slop will be closed\)
2. Once a maintainer approves with `lgtm`, you'll be added to the approved contributors list
3. Then you can submit your PR
This PR will be closed automatically. See https://github.com/($owner)/($repo_name)/blob/($default_branch)/CONTRIBUTING.md for more details."
if $dry_run {
print "(dry-run) Would post comment and close PR"
print "closed"
return
}
# Post comment
github api "post" $"/repos/($owner)/($repo_name)/issues/($pr_number)/comments" {
body: $message
}
# Close the PR
github api "patch" $"/repos/($owner)/($repo_name)/pulls/($pr_number)" {
state: "closed"
}
print "closed"
}

32
.github/scripts/github.nu vendored Normal file
View File

@@ -0,0 +1,32 @@
# GitHub API utilities for Nu scripts
# Make a GitHub API request with proper headers
export def api [
method: string, # HTTP method (get, post, patch, etc.)
endpoint: string # API endpoint (e.g., /repos/owner/repo/issues/1/comments)
body?: record # Optional request body
] {
let url = $"https://api.github.com($endpoint)"
let headers = [
Authorization $"Bearer (get-token)"
Accept "application/vnd.github+json"
X-GitHub-Api-Version "2022-11-28"
]
match $method {
"get" => { http get $url --headers $headers },
"post" => { http post $url --headers $headers $body },
"patch" => { http patch $url --headers $headers $body },
_ => { error make { msg: $"Unsupported HTTP method: ($method)" } }
}
}
# Get GitHub token from environment or gh CLI (cached in env)
def get-token [] {
if ($env.GITHUB_TOKEN? | is-not-empty) {
return $env.GITHUB_TOKEN
}
$env.GITHUB_TOKEN = (gh auth token | str trim)
$env.GITHUB_TOKEN
}

35
.github/workflows/pr-gate.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: PR Gate
on:
pull_request_target:
types: [opened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Check if contributor is approved
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
nix develop -c nu .github/scripts/approved-gate.nu pr \
-R ${{ github.repository }} \
${{ github.event.pull_request.number }} \
--dry-run=false