forked from cms-sw/cms-bot
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathprocess-pull-request
executable file
·467 lines (416 loc) · 18.4 KB
/
process-pull-request
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
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
#!/usr/bin/env python
from github import Github
from os.path import expanduser
from optparse import OptionParser
from categories import CMSSW_CATEGORIES, CMSSW_L2, CMSSW_L1, TRIGGER_PR_TESTS
from releases import RELEASE_BRANCH_MILESTONE, RELEASE_BRANCH_PRODUCTION, RELEASE_BRANCH_CLOSED
from releases import RELEASE_MANAGERS
import yaml
import re
from sys import exit
TRIGERING_TESTS_MSG = 'The tests are being triggered in jenkins.'
TESTS_RESULTS_MSG = '[-|+]1'
# Prepare various comments regardless of whether they will be made or not.
def format(s, **kwds):
return s % kwds
#
# creates a properties file to trigger the test of the pull request
#
def create_properties_file_tests( pr_number ):
out_file_name = 'trigger-tests-%s.properties' % pr_number
if opts.dryRun:
print 'Not creating cleanup properties file (dry-run): %s' % out_file_name
else:
print 'Creating properties file %s' % out_file_name
out_file = open( out_file_name , 'w' )
out_file.write( '%s=%s\n' % ( 'PULL_REQUEST_LIST', pr_number ) )
out_file.close()
# Update the milestone for a given issue.
def updateMilestone(issue, pr):
if issue.milestone:
return
branch = pr.base.label.split(":")[1]
milestoneId = RELEASE_BRANCH_MILESTONE.get(branch, None)
if not milestoneId:
print "Unable to find a milestone for the given branch"
return
milestone = repo.get_milestone(milestoneId)
print "Setting milestone to %s" % milestone.title
if opts.dryRun:
return
issue.edit(milestone=milestone)
if __name__ == "__main__":
parser = OptionParser(usage="%prog <pull-request-id>")
parser.add_option("-n", "--dry-run", dest="dryRun", action="store_true", help="Do not modify Github", default=False)
opts, args = parser.parse_args()
if len(args) != 1:
parser.error("Too many arguments")
prId = int(args[0])
gh = Github(login_or_token=open(expanduser("~/.github-token")).read().strip())
try:
pr = gh.get_repo( 'cms-sw/cmssw' ).get_pull(prId)
except:
print "Could not find pull request. Maybe this is an issue"
exit(0)
# Process the changes for the given pull request so that we can determine the
# signatures it requires.
packages = sorted([x for x in set(["/".join(x.filename.split("/", 2)[0:2])
for x in pr.get_files()])])
print "Following packages affected:"
print "\n".join(packages)
signing_categories = set([category for package in packages
for category, category_packages in CMSSW_CATEGORIES.items()
if package in category_packages])
# We always require tests.
signing_categories.add("tests")
# We require ORP approval for releases which are in production.
if pr.base.ref in RELEASE_BRANCH_PRODUCTION:
print "This pull request requires ORP approval"
signing_categories.add("orp")
print "Following categories affected:"
print "\n".join(signing_categories)
# If there is a new package, add also a dummy "new" category.
all_packages = [package for category_packages in CMSSW_CATEGORIES.values()
for package in category_packages]
has_category = all([package in all_packages for package in packages])
new_package_message = ""
if not has_category:
new_package_message = "\nThe following packages do not have a category, yet:\n\n"
new_package_message += "\n".join([package for package in packages if not package in all_packages]) + "\n"
signing_categories.add("new-package")
# Add watchers.yaml information to the WATCHERS dict.
WATCHERS = (yaml.load(file("watchers.yaml")))
# Given the packages check if there are additional developers watching one or more.
author = pr.user.login
watchers = set([user for package in packages
for user, watched_regexp in WATCHERS.items()
for regexp in watched_regexp
if re.match("^" + regexp + ".*", package) and user != author])
# Handle watchers
watchingGroups = yaml.load(file("groups.yaml"))
for watcher in [x for x in watchers]:
if not watcher in watchingGroups:
continue
watchers.remove(watcher)
watchers.update(set(watchingGroups[watcher]))
watchers = set(["@" + u for u in watchers])
print "Watchers " + ", ".join(watchers)
repo = gh.get_repo( 'cms-sw/cmssw' )
issue = repo.get_issue(prId)
updateMilestone(issue, pr)
# Process the issue comments
signatures = dict([(x, "pending") for x in signing_categories])
last_commit_date = None
try:
# This requires at least PyGithub 1.23.0. Making it optional for the moment.
last_commit_date = pr.get_commits().reversed[0].commit.committer.date
except:
# This seems to fail for more than 250 commits. Not sure if the
# problem is github itself or the bindings.
last_commit_date = pr.get_commits()[pr.commits - 1].commit.committer.date
is_hold = False
already_seen = False
pull_request_updated = False
comparison_done = False
tests_already_queued = False
tests_requested = False
# A pull request is by default closed if the branch is a closed one.
mustClose = False
mustMerge = False
if pr.base.ref in RELEASE_BRANCH_CLOSED:
mustClose = True
requiresL1 = False
releaseManagers=RELEASE_MANAGERS.get(pr.base.ref, [])
for comment in issue.get_comments():
comment_date = comment.created_at
commenter = comment.user.login
# Check special cmsbuild messages:
# - Check we did not announce the pull request already
# - Check we did not announce changes already
if commenter == "cmsbuild":
if re.match("A new Pull Request was created by", comment.body.encode("ascii", "ignore")):
already_seen = True
if re.match("Pull request [#][0-9]+ was updated[.].*", comment.body.encode("ascii", "ignore")):
pull_request_updated = False
# Ignore all other messages which are before last commit.
if comment_date < last_commit_date:
print "Ignoring comment done before the last commit."
pull_request_updated = True
continue
# The first line is an invariant.
first_line = str(comment.body.encode("ascii", "ignore").split("\n")[0].strip("\n\t\r "))
# Check for cmsbuild comments
if commenter == "cmsbuild":
if re.match("Comparison is ready", first_line):
comparison_done = True
if re.match( TRIGERING_TESTS_MSG, first_line):
tests_already_queued = True
print 'Tests already queued'
if re.match( TESTS_RESULTS_MSG, first_line):
tests_already_queued = False
tests_requested = False
print 'Previous tests already finished, resetting test request state'
# Check actions made by L1.
# L1 signatures are only relevant for closed releases where
# we have a orp signature requested.
# Approving a pull request, sign it.
# Rejecting a pull request, will also close it.
# Use "reopen" to open a closed pull request.
if commenter in CMSSW_L1:
requiresL1 = True
if not "orp" in signing_categories:
requiresL1 = False
elif re.match("^([+]1|approve[d]?)$", first_line):
signatures["orp"] = "approved"
mustClose = False
elif re.match("^([-]1|reject|rejected)$", first_line):
signatures["orp"] = "rejected"
mustClose = True
elif re.match("reopen", first_line):
signatures["orp"] = "pending"
mustClose = False
# Check if the release manager asked for merging this.
if commenter in releaseManagers:
if re.match("merge", first_line):
mustMerge = True
# Check L2 signoff for users in this PR signing categories
if commenter in CMSSW_L2 and [x for x in CMSSW_L2[commenter] if x in signing_categories]:
if re.match("^([+]1|approve[d]?|sign|signed)$", first_line):
for sign in CMSSW_L2[commenter]:
signatures[sign] = "approved"
elif re.match("^([-]1|reject|rejected)$", first_line):
for sign in CMSSW_L2[commenter]:
signatures[sign] = "rejected"
elif (commenter == "cmsbuild" and re.match( TRIGERING_TESTS_MSG, first_line) ):
signatures["tests"] = "started"
# Some of the special users can say "hold" prevent automatic merging of
# fully signed PRs.
if commenter in CMSSW_L1 + CMSSW_L2.keys() + releaseManagers:
if re.match("^hold$", first_line):
is_hold = True
blocker = commenter
# Check for release managers and and sign the tests category based on
# their comment
#+tested for approved
#-tested for rejected
if commenter in releaseManagers:
if re.match("^[+](test|tested)$", first_line):
signatures["tests"] = "approved"
elif re.match("^[-](test|tested)$", first_line):
signatures["tests"] = "rejected"
# Check if the someone asked to trigger the tests
if (commenter in TRIGGER_PR_TESTS
or commenter in releaseManagers
or commenter in CMSSW_L2.keys()):
if re.match("^(@cmsbuild[,]* |)([Pp]lease[,]* |)test$", first_line):
print 'Tests requested:', commenter, 'asked to test this PR'
tests_requested = True
print "The labels of the pull request should be:"
# Labels coming from signature.
labels = [x + "-pending" for x in signing_categories]
for category, value in signatures.items():
if not category in signing_categories:
continue
labels = [l for l in labels if not l.startswith(category)]
if value == "approved":
labels.append(category + "-approved")
elif value == "rejected":
labels.append(category + "-rejected")
elif value == "started":
labels.append(category + "-started")
else:
labels.append(category + "-pending")
# Additional labels.
if is_hold:
labels.append("hold")
if comparison_done:
labels.append("comparison-available")
else:
labels.append("comparison-pending")
print "\n".join(labels)
# Now updated the labels.
missingApprovals = [x
for x in labels
if not x.endswith("-approved")
and not x.startswith("orp")
and not x.startswith("tests")
and not x.startswith("comparison")
and not x == "hold"]
if not missingApprovals:
print "The pull request is complete."
if missingApprovals:
labels.append("pending-signatures")
else:
labels.append("fully-signed")
# We update labels only if they are different.
SUPER_USERS = (yaml.load(file("super-users.yaml")))
old_labels = [x.name for x in issue.labels]
releaseManagersList = ", ".join(["@" + x for x in set(releaseManagers + SUPER_USERS)])
releaseManagersMsg = ""
if releaseManagers:
releaseManagersMsg = format("%(rm)s can you please take care of it?",
rm=releaseManagersList)
# trigger the tests and inform it in the thread.
if tests_requested and ( not tests_already_queued ):
create_properties_file_tests( prId )
if not opts.dryRun:
pr.create_issue_comment( TRIGERING_TESTS_MSG )
# Do not complain about tests
requiresTestMessage = "or unless it breaks tests."
if "tests-approved" in set(labels):
requiresTestMessage = "(tests are also fine)."
elif "tests-rejected" in set(labels):
requiresTestMessage = "(but tests are reportedly failing)."
autoMergeMsg = ""
if all(["fully-signed" in set(labels),
not "hold" in set(labels),
not "orp-rejected" in set(labels),
not "orp-pending" in set(labels),
"tests-approved" in set(labels)]):
autoMergeMsg = "This pull request will be automatically merged."
else:
if "orp-pending" in set(labels) or "orp-rejected" in set(labels):
autoMergeMsg = format("This pull request requires discussion in the"
" ORP meeting before it's merged. %(managers)s",
managers=releaseManagersList)
elif "new-package-pending" in set(labels):
autoMergeMsg = format("This pull request requires a new package and "
" will not be merged. %(managers)s",
managers=releaseManagersList)
elif "hold" in set(labels):
autoMergeMsg = format("This PR is put on hold by @%(blocker)s. He / she"
" will have to remove the `hold` comment or"
" %(managers)s will have to merge it by"
" hand.",
blocker=blocker,
managers=releaseManagersList)
messageFullySigned = format("This pull request is fully signed and it will be"
" integrated in one of the next %(branch)s IBs"
" unless changes"
" %(requiresTest)s"
" %(autoMerge)s",
requiresTest=requiresTestMessage,
autoMerge = autoMergeMsg,
branch=pr.base.ref)
if set(old_labels) == set(labels):
print "Labels unchanged."
elif not opts.dryRun:
issue.delete_labels()
issue.add_to_labels(*[repo.get_label(x) for x in labels])
if all(["fully-signed" in labels,
not "orp-approved" in labels,
not "orp-pending" in labels]):
pr.create_issue_comment(messageFullySigned)
elif "fully-signed" in labels and "orp-approved" in labels:
pass
elif "fully-signed" in labels and "orp-pending" in labels:
pr.create_issue_comment(messageFullySigned)
unsigned = [k for (k, v) in signatures.items() if v == "pending"]
missing_notifications = ["@" + name
for name, l2_categories in CMSSW_L2.items()
for signature in signing_categories
if signature in l2_categories
and signature in unsigned]
missing_notifications = set(missing_notifications)
# Construct message for the watchers
watchersMsg = ""
if watchers:
watchersMsg = format("%(watchers)s this is something you requested to"
" watch as well.\n",
watchers=", ".join(watchers))
# Construct message for the release managers.
managers = ", ".join(["@" + x for x in releaseManagers])
releaseManagersMsg = ""
if releaseManagers:
releaseManagersMsg = format("%(managers)s you are the release manager for"
" this.\nYou can merge this pull request by"
" typing 'merge' in the first line of your"
" comment.",
managers = managers)
# Construct message for ORP approval
orpRequiredMsg = ""
if requiresL1:
orpRequiredMsg = format("\nThis pull requests was done for a production"
" branch and will require explicit ORP approval"
" on friday or L1 override.")
# We do not want to spam people for the old pull requests.
messageNewPR = format("A new Pull Request was created by @%(user)s"
" %(name)s for %(branch)s.\n\n"
"%(title)s\n\n"
"It involves the following packages:\n\n"
"%(packages)s\n\n"
"%(new_package_message)s\n"
"%(l2s)s can you please review it and eventually sign?"
" Thanks.\n"
"%(watchers)s"
"You can sign-off by replying to this message having"
" '+1' in the first line of your reply.\n"
"You can reject by replying to this message having"
" '-1' in the first line of your reply.\n"
"If you are a L2 or a release manager you can ask for"
" tests by saying 'please test' in the first line of a"
" comment.\n"
"%(releaseManagers)s"
"%(orpRequired)s",
user=pr.user.login,
name=pr.user.name and "(%s)" % pr.user.name or "",
branch=pr.base.ref,
title=pr.title.encode("ascii", "ignore"),
l2s=", ".join(missing_notifications),
packages="\n".join(packages),
new_package_message=new_package_message,
watchers=watchersMsg,
releaseManagers=releaseManagersMsg,
orpRequired=orpRequiredMsg)
messageUpdatedPR = format("Pull request #%(pr)s was updated."
" %(signers)s can you please check and sign again.",
pr=pr.number,
signers=", ".join(missing_notifications))
# Finally decide whether or not we should close the pull request:
messageBranchClosed = format("This branch is closed for updates."
" Closing this pull request.\n"
" Please bring this up in the ORP"
" meeting if really needed.\n")
commentMsg = ""
if pr.base.ref in RELEASE_BRANCH_CLOSED:
commentMsg = messageBranchClosed
elif not missingApprovals:
print "Pull request is already fully signed. Not sending message."
elif not already_seen:
commentMsg = messageNewPR
elif pull_request_updated:
commentMsg = messageUpdatedPR
else:
print "Already notified L2 about " + str(pr.number)
if commentMsg:
print "The following comment will be made:"
try:
print commentMsg.decode("ascii", "replace")
except:
pass
if commentMsg and not opts.dryRun:
pr.create_issue_comment(commentMsg)
# Check if it needs to be automatically closed.
if mustClose == True and issue.state == "open":
print "This pull request must be closed."
if not opts.dryRun:
print issue.edit(state="closed")
# Check if it needs to be automatically merged.
if all(["fully-signed" in labels,
"tests-approved" in labels,
not "hold" in labels,
not "orp-rejected" in labels,
not "orp-pending" in labels,
not "new-package-pending" in labels]):
print "This pull request can be automatically merged"
mustMerge = True
else:
print "This pull request will not be automatically merged."
print not "orp-rejected" in labels, not "orp-pending" in labels
if mustMerge == True:
print "This pull request must be merged."
if not opts.dryRun:
try:
pr.merge()
except:
pass