summaryrefslogtreecommitdiff
path: root/tool/auto_review_pr.rb
blob: 38adf9fdb7fe4202a42ed4ee0a60f492d8ef8f96 (plain)
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)