Skip to content

Commit a7a4bb9

Browse files
authored
Automatically review default-gem pull requests (#15116)
1 parent 4fe0342 commit a7a4bb9

File tree

3 files changed

+140
-0
lines changed

3 files changed

+140
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Auto Review PR
2+
on:
3+
pull_request_target:
4+
types: [opened, ready_for_review, reopened]
5+
branches: [master]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
auto-review-pr:
12+
name: Auto Review PR
13+
runs-on: ubuntu-latest
14+
if: ${{ github.repository == 'ruby/ruby' && github.base_ref == 'master' }}
15+
16+
permissions:
17+
pull-requests: write
18+
contents: read
19+
20+
steps:
21+
- name: Checkout repository
22+
uses: actions/checkout@v4
23+
24+
- uses: ruby/setup-ruby@ab177d40ee5483edb974554986f56b33477e21d0 # v1.265.0
25+
with:
26+
ruby-version: '3.4'
27+
bundler: none
28+
29+
- name: Auto Review PR
30+
run: ruby tool/auto_review_pr.rb "$GITHUB_PR_NUMBER"
31+
env:
32+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33+
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}

tool/auto_review_pr.rb

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require 'json'
5+
require 'net/http'
6+
require 'uri'
7+
require_relative './sync_default_gems'
8+
9+
class GitHubAPIClient
10+
def initialize(token)
11+
@token = token
12+
end
13+
14+
def get(path)
15+
response = Net::HTTP.get_response(URI("https://api.github.com#{path}"), {
16+
'Authorization' => "token #{@token}",
17+
'Accept' => 'application/vnd.github.v3+json',
18+
}).tap(&:value)
19+
JSON.parse(response.body, symbolize_names: true)
20+
end
21+
22+
def post(path, body = {})
23+
body = JSON.dump(body)
24+
response = Net::HTTP.post(URI("https://api.github.com#{path}"), body, {
25+
'Authorization' => "token #{@token}",
26+
'Accept' => 'application/vnd.github.v3+json',
27+
'Content-Type' => 'application/json',
28+
}).tap(&:value)
29+
JSON.parse(response.body, symbolize_names: true)
30+
end
31+
end
32+
33+
class AutoReviewPR
34+
REPO = 'ruby/ruby'
35+
36+
COMMENT_USER = 'github-actions[bot]'
37+
COMMENT_PREFIX = 'The following files are maintained in the following upstream repositories:'
38+
COMMENT_SUFFIX = 'Please file a pull request to the above instead. Thank you.'
39+
40+
def initialize(client)
41+
@client = client
42+
end
43+
44+
def review(pr_number)
45+
comment_body = "Please file a pull request to ruby/foo instead."
46+
47+
# Fetch the list of files changed by the PR
48+
changed_files = @client.get("/repos/#{REPO}/pulls/#{pr_number}/files").map { it.fetch(:filename) }
49+
50+
# Build a Hash: { upstream_repo => files, ... }
51+
upstream_repos = changed_files.group_by { |file| find_upstream_repo(file) }
52+
upstream_repos.delete(nil) # exclude no-upstream files
53+
upstream_repos.delete('prism') if changed_files.include?('prism_compile.c') # allow prism changes in this case
54+
if upstream_repos.empty?
55+
puts "Skipped: The PR ##{pr_number} doesn't have upstream repositories."
56+
return
57+
end
58+
59+
# Check if the PR is already reviewed
60+
existing_comments = @client.get("/repos/#{REPO}/issues/#{pr_number}/comments")
61+
existing_comments.map! { [it.fetch(:user).fetch(:login), it.fetch(:body)] }
62+
if existing_comments.any? { |user, comment| user == COMMENT_USER && comment.start_with?(COMMENT_PREFIX) }
63+
puts "Skipped: The PR ##{pr_number} already has an automated review comment."
64+
return
65+
end
66+
67+
# Post a comment
68+
comment = format_comment(upstream_repos)
69+
result = @client.post("/repos/#{REPO}/issues/#{pr_number}/comments", { body: comment })
70+
puts "Success: #{JSON.pretty_generate(result)}"
71+
end
72+
73+
private
74+
75+
def find_upstream_repo(file)
76+
SyncDefaultGems::REPOSITORIES.each do |repo_name, repository|
77+
repository.mappings.each do |_src, dst|
78+
if file.start_with?(dst)
79+
return repo_name
80+
end
81+
end
82+
end
83+
nil
84+
end
85+
86+
# upstream_repos: { upstream_repo => files, ... }
87+
def format_comment(upstream_repos)
88+
comment = +''
89+
comment << "#{COMMENT_PREFIX}\n\n"
90+
91+
upstream_repos.each do |upstream_repo, files|
92+
comment << "* https://github.com/ruby/#{upstream_repo}\n"
93+
files.each do |file|
94+
comment << " * #{file}\n"
95+
end
96+
end
97+
98+
comment << "\n#{COMMENT_SUFFIX}"
99+
comment
100+
end
101+
end
102+
103+
pr_number = ARGV[0] || abort("Usage: #{$0} <pr_number>")
104+
client = GitHubAPIClient.new(ENV.fetch('GITHUB_TOKEN'))
105+
106+
AutoReviewPR.new(client).review(pr_number)

tool/sync_default_gems.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def lib((upstream, branch), gemspec_in_subdir: false)
6161
])
6262
end
6363

64+
# Note: tool/auto_review_pr.rb also depends on this constant.
6465
REPOSITORIES = {
6566
"io-console": repo("ruby/io-console", [
6667
["ext/io/console", "ext/io/console"],

0 commit comments

Comments
 (0)