summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTakashi Kokubun <takashi.kokubun@shopify.com>2025-11-09 07:16:42 -0800
committerGitHub <noreply@github.com>2025-11-09 07:16:42 -0800
commita7a4bb93fc37b3d4ad5a7b2147c9adf952928342 (patch)
tree52961ebc74c099487a4ece730ce79eaabc72def7
parent4fe0342a860bc3aceb5f51707732545f83a0ac35 (diff)
Automatically review default-gem pull requests (#15116)
-rw-r--r--.github/workflows/auto_review_pr.yml33
-rwxr-xr-xtool/auto_review_pr.rb106
-rwxr-xr-xtool/sync_default_gems.rb1
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"],