From 39e610d0ee1dc4e58b3e2241dbef8b52b2db3bf3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 3 Feb 2026 09:18:24 -0800 Subject: [PATCH] approved PR gate --- .github/scripts/approve-contributor.nu | 40 ++------- .github/scripts/approved-gate.nu | 112 +++++++++++++++++++++++++ .github/scripts/github.nu | 32 +++++++ .github/workflows/pr-gate.yml | 35 ++++++++ 4 files changed, 185 insertions(+), 34 deletions(-) create mode 100755 .github/scripts/approved-gate.nu create mode 100644 .github/scripts/github.nu create mode 100644 .github/workflows/pr-gate.yml diff --git a/.github/scripts/approve-contributor.nu b/.github/scripts/approve-contributor.nu index cafc877fc..6edfeabb0 100755 --- a/.github/scripts/approve-contributor.nu +++ b/.github/scripts/approve-contributor.nu @@ -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 -} diff --git a/.github/scripts/approved-gate.nu b/.github/scripts/approved-gate.nu new file mode 100755 index 000000000..2ff1e612a --- /dev/null +++ b/.github/scripts/approved-gate.nu @@ -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 " + 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" +} diff --git a/.github/scripts/github.nu b/.github/scripts/github.nu new file mode 100644 index 000000000..eff7d5347 --- /dev/null +++ b/.github/scripts/github.nu @@ -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 +} diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 000000000..bf0dd1b99 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -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