diff options
| author | Takashi Kokubun <takashi.kokubun@shopify.com> | 2025-11-09 07:16:42 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-09 07:16:42 -0800 |
| commit | a7a4bb93fc37b3d4ad5a7b2147c9adf952928342 (patch) | |
| tree | 52961ebc74c099487a4ece730ce79eaabc72def7 | |
| parent | 4fe0342a860bc3aceb5f51707732545f83a0ac35 (diff) | |
Automatically review default-gem pull requests (#15116)
| -rw-r--r-- | .github/workflows/auto_review_pr.yml | 33 | ||||
| -rwxr-xr-x | tool/auto_review_pr.rb | 106 | ||||
| -rwxr-xr-x | tool/sync_default_gems.rb | 1 |
3 files changed, 140 insertions, 0 deletions
diff --git a/.github/workflows/auto_review_pr.yml b/.github/workflows/auto_review_pr.yml new file mode 100644 index 0000000000..c8095dfd8e --- /dev/null +++ b/.github/workflows/auto_review_pr.yml @@ -0,0 +1,33 @@ +name: Auto Review PR +on: + pull_request_target: + types: [opened, ready_for_review, reopened] + branches: [master] + +permissions: + contents: read + +jobs: + auto-review-pr: + name: Auto Review PR + runs-on: ubuntu-latest + if: ${{ github.repository == 'ruby/ruby' && github.base_ref == 'master' }} + + permissions: + pull-requests: write + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@ab177d40ee5483edb974554986f56b33477e21d0 # v1.265.0 + with: + ruby-version: '3.4' + bundler: none + + - name: Auto Review PR + run: ruby tool/auto_review_pr.rb "$GITHUB_PR_NUMBER" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/tool/auto_review_pr.rb b/tool/auto_review_pr.rb new file mode 100755 index 0000000000..a3494e4c1a --- /dev/null +++ b/tool/auto_review_pr.rb @@ -0,0 +1,106 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'uri' +require_relative './sync_default_gems' + +class GitHubAPIClient + def initialize(token) + @token = token + end + + def get(path) + response = Net::HTTP.get_response(URI("https://api.github.com#{path}"), { + 'Authorization' => "token #{@token}", + 'Accept' => 'application/vnd.github.v3+json', + }).tap(&:value) + JSON.parse(response.body, symbolize_names: true) + end + + def post(path, body = {}) + body = JSON.dump(body) + response = Net::HTTP.post(URI("https://api.github.com#{path}"), body, { + 'Authorization' => "token #{@token}", + 'Accept' => 'application/vnd.github.v3+json', + 'Content-Type' => 'application/json', + }).tap(&:value) + JSON.parse(response.body, symbolize_names: true) + end +end + +class AutoReviewPR + REPO = 'ruby/ruby' + + COMMENT_USER = 'github-actions[bot]' + COMMENT_PREFIX = 'The following files are maintained in the following upstream repositories:' + COMMENT_SUFFIX = 'Please file a pull request to the above instead. Thank you.' + + def initialize(client) + @client = client + end + + def review(pr_number) + comment_body = "Please file a pull request to ruby/foo instead." + + # Fetch the list of files changed by the PR + changed_files = @client.get("/repos/#{REPO}/pulls/#{pr_number}/files").map { it.fetch(:filename) } + + # Build a Hash: { upstream_repo => files, ... } + upstream_repos = changed_files.group_by { |file| find_upstream_repo(file) } + upstream_repos.delete(nil) # exclude no-upstream files + upstream_repos.delete('prism') if changed_files.include?('prism_compile.c') # allow prism changes in this case + if upstream_repos.empty? + puts "Skipped: The PR ##{pr_number} doesn't have upstream repositories." + return + end + + # Check if the PR is already reviewed + existing_comments = @client.get("/repos/#{REPO}/issues/#{pr_number}/comments") + existing_comments.map! { [it.fetch(:user).fetch(:login), it.fetch(:body)] } + if existing_comments.any? { |user, comment| user == COMMENT_USER && comment.start_with?(COMMENT_PREFIX) } + puts "Skipped: The PR ##{pr_number} already has an automated review comment." + return + end + + # Post a comment + comment = format_comment(upstream_repos) + result = @client.post("/repos/#{REPO}/issues/#{pr_number}/comments", { body: comment }) + puts "Success: #{JSON.pretty_generate(result)}" + end + + private + + def find_upstream_repo(file) + SyncDefaultGems::REPOSITORIES.each do |repo_name, repository| + repository.mappings.each do |_src, dst| + if file.start_with?(dst) + return repo_name + end + end + end + nil + end + + # upstream_repos: { upstream_repo => files, ... } + def format_comment(upstream_repos) + comment = +'' + comment << "#{COMMENT_PREFIX}\n\n" + + upstream_repos.each do |upstream_repo, files| + comment << "* https://github.com/ruby/#{upstream_repo}\n" + files.each do |file| + comment << " * #{file}\n" + end + end + + comment << "\n#{COMMENT_SUFFIX}" + comment + end +end + +pr_number = ARGV[0] || abort("Usage: #{$0} <pr_number>") +client = GitHubAPIClient.new(ENV.fetch('GITHUB_TOKEN')) + +AutoReviewPR.new(client).review(pr_number) diff --git a/tool/sync_default_gems.rb b/tool/sync_default_gems.rb index b2509f0062..997cd5eddc 100755 --- a/tool/sync_default_gems.rb +++ b/tool/sync_default_gems.rb @@ -61,6 +61,7 @@ module SyncDefaultGems ]) end + # Note: tool/auto_review_pr.rb also depends on this constant. REPOSITORIES = { "io-console": repo("ruby/io-console", [ ["ext/io/console", "ext/io/console"], |
