1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
|
#!/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]'
UPSTREAM_COMMENT_PREFIX = 'The following files are maintained in the following upstream repositories:'
UPSTREAM_COMMENT_SUFFIX = 'Please file a pull request to the above instead. Thank you!'
REDMINE_TICKET_PATTERN = /\[(Bug|Feature|Misc)\s*#(\d+)\]/
REDMINE_COMMENT_PREFIX = 'This pull request references the following Redmine tickets:'
FORK_COMMENT_PREFIX = 'It looks like this pull request was filed from a branch in ruby/ruby.'
FORK_COMMENT_BODY = <<~COMMENT
#{FORK_COMMENT_PREFIX}
Since ruby/ruby is bi-directionally mirrored with the official git repository at git.ruby-lang.org, \
having topic branches in ruby/ruby makes it harder to manage the mirror.
Could you please close this pull request and re-file it from a branch in your personal fork instead? \
You can fork https://github.com/ruby/ruby, push your branch there, and open a new pull request from it.
Thank you for your contribution!
COMMENT
def initialize(client)
@client = client
end
def review(pr_number)
existing_comments = fetch_existing_comments(pr_number)
pr = @client.get("/repos/#{REPO}/pulls/#{pr_number}")
review_non_fork_branch(pr_number, pr, existing_comments)
review_upstream_repos(pr_number, existing_comments)
review_redmine_links(pr_number, pr, existing_comments)
end
private
def fetch_existing_comments(pr_number)
comments = @client.get("/repos/#{REPO}/issues/#{pr_number}/comments")
comments.map { [it.fetch(:user).fetch(:login), it.fetch(:body)] }
end
def already_commented?(existing_comments, prefix)
existing_comments.any? { |user, comment| user == COMMENT_USER && comment.start_with?(prefix) }
end
def post_comment(pr_number, comment)
result = @client.post("/repos/#{REPO}/issues/#{pr_number}/comments", { body: comment })
puts "Success: #{JSON.pretty_generate(result)}"
end
# Suggest re-filing from a fork if the PR branch is in ruby/ruby itself
def review_non_fork_branch(pr_number, pr, existing_comments)
if already_commented?(existing_comments, FORK_COMMENT_PREFIX)
puts "Skipped: The PR ##{pr_number} already has a fork branch comment."
return
end
head_repo = pr.dig(:head, :repo, :full_name)
if head_repo != REPO
puts "Skipped: The PR ##{pr_number} is already from a fork (#{head_repo})."
return
end
author = pr.dig(:user, :login)
if author == 'dependabot[bot]'
puts "Skipped: The PR ##{pr_number} is from dependabot."
return
end
post_comment(pr_number, FORK_COMMENT_BODY)
end
# Suggest filing PRs to upstream repositories for files that have one
def review_upstream_repos(pr_number, existing_comments)
if already_commented?(existing_comments, UPSTREAM_COMMENT_PREFIX)
puts "Skipped: The PR ##{pr_number} already has an upstream repos comment."
return
end
changed_files = @client.get("/repos/#{REPO}/pulls/#{pr_number}/files").map { it.fetch(:filename) }
upstream_repos = SyncDefaultGems::Repository.group(changed_files)
upstream_repos.delete(nil)
upstream_repos.delete('prism') if changed_files.include?('prism_compile.c')
if upstream_repos.empty?
puts "Skipped: The PR ##{pr_number} doesn't have upstream repositories."
return
end
post_comment(pr_number, format_upstream_comment(upstream_repos))
end
def review_redmine_links(pr_number, pr, existing_comments)
if already_commented?(existing_comments, REDMINE_COMMENT_PREFIX)
puts "Skipped: The PR ##{pr_number} already has a Redmine links comment."
return
end
text = "#{pr[:title]}\n#{pr[:body]}"
tickets = text.scan(REDMINE_TICKET_PATTERN).uniq
tickets.reject! { |_, number| text.include?("https://bugs.ruby-lang.org/issues/#{number}") }
if tickets.empty?
puts "Skipped: The PR ##{pr_number} doesn't reference any Redmine tickets."
return
end
post_comment(pr_number, format_redmine_comment(tickets))
end
def format_redmine_comment(tickets)
comment = +"#{REDMINE_COMMENT_PREFIX}\n\n"
tickets.each do |type, number|
comment << "* [#{type} ##{number}](https://bugs.ruby-lang.org/issues/#{number})\n"
end
comment
end
def format_upstream_comment(upstream_repos)
comment = +''
comment << "#{UPSTREAM_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#{UPSTREAM_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)
|