/
github.rb
390 lines (350 loc) · 15.7 KB
/
github.rb
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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
require 'net/http'
require 'octokit'
require_relative '../../deployment'
# This module serves as a thin wrapper around Octokit, itself a wrapper around
# the GitHub API.
module GitHub
ORGANIZATION = 'code-dot-org'.freeze
REPO = "#{ORGANIZATION}/code-dot-org".freeze
DASHBOARD_DB_DIR = 'dashboard/db/'.freeze
PEGASUS_DB_DIR = 'pegasus/migrations/'.freeze
STAGING_BRANCH = 'staging'.freeze
STAGING_NEXT_BRANCH = 'staging-next'.freeze
STATUS_SUCCESS = 'success'.freeze
STATUS_FAILURE = 'failure'.freeze
STATUS_CONTEXT = 'DTS'.freeze
STATUS_CONTEXT_DTSN = 'DTSN'.freeze
# Configures Octokit with our GitHub access token.
# @raise [RuntimeError] If CDO.github_access_token is not defined.
def self.configure_octokit
unless CDO.github_access_token
raise "CDO.github_access_token undefined"
end
Octokit.configure do |client|
client.access_token = CDO.github_access_token
end
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/PullRequests.html#pull_request_files-instance_method
# @param pr_number [Integer] The PR number to query.
# @return [Array[String]] The filenames part of the pull request living in the dashboard or
# pegasus migrations subdirectory.
def self.database_changes(pr_number)
# For pagination documentation, see https://github.com/octokit/octokit.rb#pagination.
Octokit.auto_paginate = true
response = Octokit.pull_request_files(REPO, pr_number)
filenames = response.map {|resource| resource[:filename]}
filenames.select do |filename|
(filename.start_with? DASHBOARD_DB_DIR) || (filename.start_with? PEGASUS_DB_DIR)
end
end
# @param branch_name [String] The name of the branch to check for
# @param at_commit [String] Optional: A commit hash which we expect to match
# the latest commit to the specified branch
# @return [Boolean] Whether or not a branch with the specified name already
# exists in the repository.
def self.branch_exists?(branch_name, at_commit: nil)
configure_octokit
response = Octokit.branch(REPO, branch_name)
return false unless response
return response.commit.sha.start_with?(at_commit) if at_commit.present?
return true
rescue Octokit::NotFound
return false
end
# Creates a new branch with the given name based on the base_branch branch and
# merges the given commit into it. If all goes well, pushes the new branch to GitHub.
# Note: assumes it is run from an environment with a git worktree called
# deploy-management-repo.
# @param [String] branch_name The name of the branch to create.
# @param [String] commit The the commit that will be merged in. Can either be a sha or
# a branch name.
# @param [String] base_branch The name of the branch that changes will be merged into.
def self.create_branch_from_commit(branch_name:, commit:, base_branch:)
# check out a new branch based on base_branch
prefix_command = 'cd ~/deploy-management-repo'
created_branch = system [
prefix_command,
'git fetch',
"git checkout -b #{branch_name} #{base_branch}",
].join(' && ')
return false unless created_branch
# merge the commit into the branch
system "#{prefix_command} && git merge #{commit} --no-edit"
# check for conflicts
conflicts = `#{prefix_command} && git ls-files -u | wc -l`.to_i
if conflicts > 0
# if there are conflicts, abort the merge and cleanup
system [
prefix_command,
'git merge --abort',
"git checkout #{base_branch}",
"git branch -D #{branch_name}"
].join(' && ')
return false
else
# otherwise, push the new branch!
system "#{prefix_command} && git push origin #{branch_name}"
return true
end
end
# Deletes a branch with the given name, both locally and on GitHub.
# Note: assumes it is run from an environment with a git worktree called
# deploy-management-repo.
# @param [String] branch_name The name of the branch to delete.
# @param [String] base_branch A branch that can be checked out while deleting branch_name.
def self.delete_branch(branch_name:, base_branch:)
system [
'cd ~/deploy-management-repo',
# you can't delete a branch that you are on, so checking out something else
"git checkout #{base_branch}",
"git branch -D #{branch_name}",
"git push origin --delete #{branch_name}"
].join(' && ')
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/PullRequests.html#create_pull_request-instance_method
# @param base [String] The base branch of the requested pull request.
# @param head [String] The head branch of the requested pull request.
# @param title [String] The title of the requested pull request.
# @param body [String] The body for the pull request (optional). Supports GFM.
# @raise [Exception] From calling Octokit.create_pull_request.
# @return [nil | Integer] The PR number of the newly created DTT if successful
def self.create_pull_request(base:, head:, title:, body: nil)
configure_octokit
response = Octokit.create_pull_request(REPO, base, head, title, body)
response['number']
end
# Octokit Documentation: https://octokit.github.io/octokit.rb/Octokit/Client/PullRequests.html#pull_requests-instance_method
# @param base [String] The base branch of the requested pull request.
# @param head [String] The head branch of the requested pull request.
# @param title [String] The title of the requested pull request.
# @param body [String] The body for the pull request (optional). Supports GFM.
# @return [nil | Integer] The PR number of the first PR found for this base
# and head, or a new PR if one didn't already exist.
def self.find_or_create_pull_request(base:, head:, title:, body: nil)
configure_octokit
# The "List pull requests" endpoint has special requirements for the `head` property.
# See https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests--parameters
formatted_head = "#{ORGANIZATION}:#{head}"
existing_pull_requests = Octokit.pull_requests(REPO, {base: base, head: formatted_head})
return existing_pull_requests.first['number'] unless existing_pull_requests.empty?
return create_pull_request(base: base, head: head, title: title, body: body)
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/Issues.html#update_issue-instance_method
# @param base [String | Integer] the numeric id of the PR to be updated
# @param lables [Array[String]] array of strings to be set as new labels for the PR
# @raise [Exception] From calling Octokit.create_pull_request.
# @return [Array[String]] the resulting labels for the PR
def self.label_pull_request(id, labels)
configure_octokit
response = Octokit.update_issue(REPO, id, {labels: labels})
response['labels'].map {|label| label[:name]}
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/PullRequests.html#merge_pull_request-instance_method
# @param pr_number [Integer] The PR number to be merged.
# @param commit_message [String] The message to add to the commit
# @raise [ArgumentError] If the PR has already been merged.
# @raise [Exception] From calling Octokit.merge_pull_request.
# @return [Boolean] Whether the PR was merged.
def self.merge_pull_request(pr_number, commit_message = '')
configure_octokit
# Let async mergeability check finish before proceeding.
# The value of the mergeable attribute can be true, false, or null. If the
# value is null, this means that the mergeability hasn't been computed
# yet, and a background job was started to compute it. Give the job a few
# moments to complete, and then submit the request again. When the job is
# complete, the response will include a non-null value for the mergeable
# attribute.
# Source: https://developer.github.com/v3/pulls/#get-a-single-pull-request
pr = nil
attempt_count = 0
loop do
pr = Octokit.pull_request(REPO, pr_number)
attempt_count += 1
break unless pr['mergeable'].nil? && attempt_count < 30
sleep 1
end
if attempt_count >= 30
raise ArgumentError.new("PR##{pr_number} mergeability check timed out")
elsif pr['merged']
raise ArgumentError.new("PR##{pr_number} is already merged")
elsif !pr['mergeable']
raise ArgumentError.new("PR##{pr_number} is not mergeable")
end
response = Octokit.merge_pull_request(REPO, pr_number, commit_message)
response['merged']
end
# Creates and merges a pull request.
# @param base [String] The base branch of the requested pull request.
# @param head [String] The head branch of the requested pull request.
# @param title [String] The title of the requested pull request.
# @raise [Exception] From calling create_pull_request and merge_pull_request.
# @example For a DTT:
# create_and_merge_pull_request(base: 'test', head: 'staging', title: 'DTT')
# @return [nil | Integer] The PR number of the newly created DTT if successful
# or nil if unsuccessful or unnecessary.
def self.create_and_merge_pull_request(base:, head:, title:)
return nil unless behind?(base: head, compare: base)
pr_number = create_pull_request(base: base, head: head, title: title)
success = merge_pull_request(pr_number, title)
success ? pr_number : nil
end
# Builds the GitHub URL from a pull request number. Does not validate the pull
# request number.
# @param pr_number [Integer] The pull request number.
# @return [String] The HTML URL for the pull request.
def self.url(pr_number)
"https://github.com/#{REPO}/pull/#{pr_number}"
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/PullRequests.html#pull_merged
# @param pr_number [Integer] The number of the pull request to check.
# @raise [Exception] From calling Octokit.pull_merged?.
# @return [Boolean] Whether the pull request has been merged.
def self.pull_merged?(pr_number)
Octokit.pull_merged?(REPO, pr_number)
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/Commits.html#compare-instance_method
# @param base [String] The base branch of the requested pull request.
# @param head [String] The head branch of the requested pull request.
# @raise [Exception] From calling Octokit.compare.
# @example For a DTT, compare(base: 'test', head: 'staging').
# @return [Array[String]] The commit messages of all commits between base and
# head.
def self.compare(base:, head:)
base_sha = sha(base)
head_sha = sha(head)
response = Octokit.compare(REPO, base_sha, head_sha)
response.commits.map(&:commit).map(&:message)
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/Commits.html#compare-instance_method
# @param base [String] The base branch to compare against.
# @param compare [String] The comparison branch to compare.
# @return [Boolean] Whether compare is behind base, i.e., whether compare is missing
# commits in base.
def self.behind?(base:, compare:)
response = Octokit.compare(REPO, base, compare)
response.behind_by > 0
rescue Octokit::InternalServerError
# This can happen for comparisons with extremely large diffs. See https://developer.github.com/v3/repos/commits/#compare-two-commits
# In this case, we can safely assume that we are indeed behind, since there
# otherwise would not be a diff to break on
true
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/Commits.html#compare-instance_method
# @param base [String] The base branch to compare against.
# @param compare [String] The comparison branch to compare.
# @return [Boolean] Whether compare is ahead of base, i.e., whether compare has commits
# missing in base.
def self.ahead?(base:, compare:)
response = Octokit.compare(REPO, base, compare)
response.ahead_by > 0
rescue Octokit::InternalServerError
# This can happen for comparisons with extremely large diffs. See https://developer.github.com/v3/repos/commits/#compare-two-commits
# In this case, we can safely assume that we are indeed ahead, since there
# otherwise would not be a diff to break on
true
end
# Octokit Documentation: http://octokit.github.io/octokit.rb/Octokit/Client/Repositories.html#branch-instance_method
# @param branch [String] The name of the branch.
# @raise [Octokit::NotFound] If the specified branch does not exist.
# @return [String] The sha hash (abbreviated to eight characters) of the most
# recent commit to branch.
def self.sha(branch, authenticate_api_request = false)
configure_octokit if authenticate_api_request
response = Octokit.branch(REPO, branch)
response.commit.sha[0..7]
end
# Opens a browser URL with a candidate pull request merging head into base.
# @param base [String] The base branch of the comparison.
# @param head [String] The head branch of the comparison.
# @param title [String] The title of the candidate pull request.
# @raise [RuntimeError] If the environment is not development.
def self.open_pull_request_in_browser(base:, head:, title:)
open_url "https://github.com/#{REPO}/compare/#{base}...#{head}" \
"?expand=1&title=#{CGI.escape title}"
end
def self.open_url(url)
raise "GitHub.open_url called on non-dev environment" unless rack_env?(:development)
# Based on http://stackoverflow.com/a/14053693/5000129
if /linux|bsd/.match?(RbConfig::CONFIG['host_os'])
system "sensible-browser \"#{url}\""
else
system "open \"#{url}\""
end
end
def self.set_dts_check_pass(pull)
Octokit.create_status(
pull['base']['repo']['full_name'],
pull['head']['sha'],
STATUS_SUCCESS,
context: STATUS_CONTEXT,
description: 'The staging branch is open.'
)
end
def self.set_all_dts_check_pass
configure_octokit
Octokit.pulls(REPO, base: STAGING_BRANCH)
paged_for_each(Octokit.last_response) do |pull|
set_dts_check_pass(pull)
end
end
def self.set_dts_check_fail(pull)
Octokit.create_status(
pull['base']['repo']['full_name'],
pull['head']['sha'],
STATUS_FAILURE,
context: STATUS_CONTEXT,
description: 'The staging branch is closed. Check #developers.'
)
end
def self.set_all_dts_check_fail
configure_octokit
Octokit.pulls(REPO, base: STAGING_BRANCH)
paged_for_each(Octokit.last_response) do |pull|
set_dts_check_fail(pull)
end
end
def self.set_dtsn_check_pass(pull)
Octokit.create_status(
pull['base']['repo']['full_name'],
pull['head']['sha'],
STATUS_SUCCESS,
context: STATUS_CONTEXT_DTSN,
description: 'The staging-next branch is open.'
)
end
def self.set_all_dtsn_check_pass
configure_octokit
Octokit.pulls(REPO, base: STAGING_NEXT_BRANCH)
paged_for_each(Octokit.last_response) do |pull|
set_dtsn_check_pass(pull)
end
end
def self.set_dtsn_check_fail(pull)
Octokit.create_status(
pull['base']['repo']['full_name'],
pull['head']['sha'],
STATUS_FAILURE,
context: STATUS_CONTEXT_DTSN,
description: 'The staging-next branch is closed. Check #developers.'
)
end
def self.set_all_dtsn_check_fail
configure_octokit
Octokit.pulls(REPO, base: STAGING_NEXT_BRANCH)
paged_for_each(Octokit.last_response) do |pull|
set_dtsn_check_fail(pull)
end
end
# Iterate over a paged resource, given the first response
def self.paged_for_each(response, &block)
loop do
resources = response.data
resources.each(&block)
break unless response.rels[:next]
response = response.rels[:next].get
end
end
def self.get_date_for_commit(commit_sha)
return Octokit.commit(REPO, commit_sha)[:commit][:author][:date]
end
end