forked from apache/hbase
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsubmit-patch.py
More file actions
executable file
·310 lines (263 loc) · 14.2 KB
/
submit-patch.py
File metadata and controls
executable file
·310 lines (263 loc) · 14.2 KB
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
#!/usr/bin/env python
##
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Makes a patch for the current branch, creates/updates the review board request and uploads new
# patch to jira. Patch is named as (JIRA).(branch name).(patch number).patch as per Yetus' naming
# rules. If no jira is specified, patch will be named (branch name).patch and jira and review board
# are not updated. Review board id is retrieved from the remote link in the jira.
# Print help: submit-patch.py --h
import argparse
import getpass
import git
import json
import logging
import os
import re
import requests
import subprocess
import sys
parser = argparse.ArgumentParser(
epilog = "To avoid having to enter jira/review board username/password every time, setup an "
"encrypted ~/.apache-cred files as follows:\n"
"1) Create a file with following single "
"line: \n{\"jira_username\" : \"appy\", \"jira_password\":\"123\", "
"\"rb_username\":\"appy\", \"rb_password\" : \"@#$\"}\n"
"2) Encrypt it with openssl.\n"
"openssl enc -aes-256-cbc -in <file> -out ~/.apache-creds\n"
"3) Delete original file.\n"
"Now onwards, you'll need to enter this encryption key only once per run. If you "
"forget the key, simply regenerate ~/.apache-cred file again.",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument("-b", "--branch",
help = "Branch to use for generating diff. If not specified, tracking branch "
"is used. If there is no tracking branch, error will be thrown.")
# Arguments related to Jira.
parser.add_argument("-jid", "--jira-id",
help = "Jira id of the issue. If set, we deduce next patch version from "
"attachments in the jira and also upload the new patch. Script will "
"ask for jira username/password for authentication. If not set, "
"patch is named <branch>.patch.")
# Arguments related to Review Board.
parser.add_argument("-srb", "--skip-review-board",
help = "Don't create/update the review board.",
default = False, action = "store_true")
parser.add_argument("--reviewers",
help = "Comma separated list of users to add as reviewers.")
# Misc arguments
parser.add_argument("--patch-dir", default = "~/patches",
help = "Directory to store patch files. If it doesn't exist, it will be "
"created. Default: ~/patches")
parser.add_argument("--rb-repo", default = "hbase-git",
help = "Review board repository. Default: hbase-git")
args = parser.parse_args()
# Setup logger
logging.basicConfig()
logger = logging.getLogger("submit-patch")
logger.setLevel(logging.INFO)
def log_fatal_and_exit(*arg):
logger.fatal(*arg)
sys.exit(1)
def assert_status_code(response, expected_status_code, description):
if response.status_code != expected_status_code:
log_fatal_and_exit(" Oops, something went wrong when %s. \nResponse: %s %s\nExiting..",
description, response.status_code, response.reason)
# Make repo instance to interact with git repo.
try:
repo = git.Repo(os.getcwd())
git = repo.git
except git.exc.InvalidGitRepositoryError as e:
log_fatal_and_exit(" '%s' is not valid git repo directory.\nRun from base directory of "
"HBase's git repo.", e)
logger.info(" Active branch: %s", repo.active_branch.name)
# Do not proceed if there are uncommitted changes.
if repo.is_dirty():
log_fatal_and_exit(" Git status is dirty. Commit locally first.")
# Returns base branch for creating diff.
def get_base_branch():
# if --branch is set, use it as base branch for computing diff. Also check that it's a valid branch.
if args.branch is not None:
base_branch = args.branch
# Check that given branch exists.
for ref in repo.refs:
if ref.name == base_branch:
return base_branch
log_fatal_and_exit(" Branch '%s' does not exist in refs.", base_branch)
else:
# if --branch is not set, use tracking branch as base branch for computing diff.
# If there is no tracking branch, log error and quit.
tracking_branch = repo.active_branch.tracking_branch()
if tracking_branch is None:
log_fatal_and_exit(" Active branch doesn't have a tracking_branch. Please specify base "
" branch for computing diff using --branch flag.")
logger.info(" Using tracking branch as base branch")
return tracking_branch.name
# Returns patch name having format (JIRA).(branch name).(patch number).patch. If no jira is
# specified, patch is name (branch name).patch.
def get_patch_name(branch):
if args.jira_id is None:
return branch + ".patch"
patch_name_prefix = args.jira_id.upper() + "." + branch
return get_patch_name_with_version(patch_name_prefix)
# Fetches list of attachments from the jira, deduces next version for the patch and returns final
# patch name.
def get_patch_name_with_version(patch_name_prefix):
# JIRA's rest api is broken wrt to attachments. https://jira.atlassian.com/browse/JRA-27637.
# Using crude way to get list of attachments.
url = "https://issues.apache.org/jira/browse/" + args.jira_id
logger.info("Getting list of attachments for jira %s from %s", args.jira_id, url)
html = requests.get(url)
if html.status_code == 404:
log_fatal_and_exit(" Invalid jira id : %s", args.jira_id)
if html.status_code != 200:
log_fatal_and_exit(" Cannot fetch jira information. Status code %s", html.status_code)
# Iterate over patch names starting from version 1 and return when name is not already used.
content = unicode(html.content, 'utf-8')
for i in range(1, 1000):
name = patch_name_prefix + "." + ('{0:03d}'.format(i)) + ".patch"
if name not in content:
return name
# Validates that patch directory exists, if not, creates it.
def validate_patch_dir(patch_dir):
# Create patch_dir if it doesn't exist.
if not os.path.exists(patch_dir):
logger.warn(" Patch directory doesn't exist. Creating it.")
os.mkdir(patch_dir)
else:
# If patch_dir exists, make sure it's a directory.
if not os.path.isdir(patch_dir):
log_fatal_and_exit(" '%s' exists but is not a directory. Specify another directory.",
patch_dir)
# Make sure current branch is ahead of base_branch by exactly 1 commit. Quits if
# - base_branch has commits not in current branch
# - current branch is same as base branch
# - current branch is ahead of base_branch by more than 1 commits
def check_diff_between_branches(base_branch):
only_in_base_branch = list(repo.iter_commits("HEAD.." + base_branch))
only_in_active_branch = list(repo.iter_commits(base_branch + "..HEAD"))
if len(only_in_base_branch) != 0:
log_fatal_and_exit(" '%s' is ahead of current branch by %s commits. Rebase "
"and try again.", base_branch, len(only_in_base_branch))
if len(only_in_active_branch) == 0:
log_fatal_and_exit(" Current branch is same as '%s'. Exiting...", base_branch)
if len(only_in_active_branch) > 1:
log_fatal_and_exit(" Current branch is ahead of '%s' by %s commits. Squash into single "
"commit and try again.", base_branch, len(only_in_active_branch))
# If ~/.apache-creds is present, load credentials from it otherwise prompt user.
def get_credentials():
creds = dict()
creds_filepath = os.path.expanduser("~/.apache-creds")
if os.path.exists(creds_filepath):
try:
logger.info(" Reading ~/.apache-creds for Jira and ReviewBoard credentials")
content = subprocess.check_output("openssl enc -aes-256-cbc -d -in " + creds_filepath,
shell=True)
except subprocess.CalledProcessError as e:
log_fatal_and_exit(" Couldn't decrypt ~/.apache-creds file. Exiting..")
creds = json.loads(content)
else:
creds['jira_username'] = raw_input("Jira username:")
creds['jira_password'] = getpass.getpass("Jira password:")
if not args.skip_review_board:
creds['rb_username'] = raw_input("Review Board username:")
creds['rb_password'] = getpass.getpass("Review Board password:")
return creds
def attach_patch_to_jira(issue_url, patch_filepath, patch_filename, creds):
# Upload patch to jira using REST API.
headers = {'X-Atlassian-Token': 'no-check'}
files = {'file': (patch_filename, open(patch_filepath, 'rb'), 'text/plain')}
jira_auth = requests.auth.HTTPBasicAuth(creds['jira_username'], creds['jira_password'])
attachment_url = issue_url + "/attachments"
r = requests.post(attachment_url, headers = headers, files = files, auth = jira_auth)
assert_status_code(r, 200, "uploading patch to jira")
def get_jira_summary(issue_url):
r = requests.get(issue_url + "?fields=summary")
assert_status_code(r, 200, "fetching jira summary")
return json.loads(r.content)["fields"]["summary"]
def get_review_board_id_if_present(issue_url, rb_link_title):
r = requests.get(issue_url + "/remotelink")
assert_status_code(r, 200, "fetching remote links")
links = json.loads(r.content)
for link in links:
if link["object"]["title"] == rb_link_title:
res = re.search("reviews.apache.org/r/([0-9]+)", link["object"]["url"])
return res.group(1)
return None
base_branch = get_base_branch()
# Remove remote repo name from branch name if present. This assumes that we don't use '/' in
# actual branch names.
base_branch_without_remote = base_branch.split('/')[-1]
logger.info(" Base branch: %s", base_branch)
check_diff_between_branches(base_branch)
patch_dir = os.path.abspath(os.path.expanduser(args.patch_dir))
logger.info(" Patch directory: %s", patch_dir)
validate_patch_dir(patch_dir)
patch_filename = get_patch_name(base_branch_without_remote)
logger.info(" Patch name: %s", patch_filename)
patch_filepath = os.path.join(patch_dir, patch_filename)
diff = git.format_patch(base_branch, stdout = True)
with open(patch_filepath, "wb") as f:
f.write(diff.encode('utf8'))
if args.jira_id is not None:
creds = get_credentials()
issue_url = "https://issues.apache.org/jira/rest/api/2/issue/" + args.jira_id
attach_patch_to_jira(issue_url, patch_filepath, patch_filename, creds)
if not args.skip_review_board:
rb_auth = requests.auth.HTTPBasicAuth(creds['rb_username'], creds['rb_password'])
rb_link_title = "Review Board (" + base_branch_without_remote + ")"
rb_id = get_review_board_id_if_present(issue_url, rb_link_title)
# If no review board link found, create new review request and add its link to jira.
if rb_id is None:
reviews_url = "https://reviews.apache.org/api/review-requests/"
data = {"repository" : "hbase-git"}
r = requests.post(reviews_url, data = data, auth = rb_auth)
assert_status_code(r, 201, "creating new review request")
review_request = json.loads(r.content)["review_request"]
absolute_url = review_request["absolute_url"]
logger.info(" Created new review request: %s", absolute_url)
# Use jira summary as review's summary too.
summary = get_jira_summary(issue_url)
# Use commit message as description.
description = repo.head.commit.message
update_draft_data = {"bugs_closed" : [args.jira_id.upper()], "target_groups" : "hbase",
"target_people" : args.reviewers, "summary" : summary,
"description" : description }
draft_url = review_request["links"]["draft"]["href"]
r = requests.put(draft_url, data = update_draft_data, auth = rb_auth)
assert_status_code(r, 200, "updating review draft")
draft_request = json.loads(r.content)["draft"]
diff_url = draft_request["links"]["draft_diffs"]["href"]
files = {'path' : (patch_filename, open(patch_filepath, 'rb'))}
r = requests.post(diff_url, files = files, auth = rb_auth)
assert_status_code(r, 201, "uploading diff to review draft")
r = requests.put(draft_url, data = {"public" : True}, auth = rb_auth)
assert_status_code(r, 200, "publishing review request")
# Add link to review board in the jira.
remote_link = json.dumps({'object': {'url': absolute_url, 'title': rb_link_title}})
jira_auth = requests.auth.HTTPBasicAuth(creds['jira_username'], creds['jira_password'])
r = requests.post(issue_url + "/remotelink", data = remote_link, auth = jira_auth,
headers={'Content-Type':'application/json'})
else:
logger.info(" Updating existing review board: https://reviews.apache.org/r/%s", rb_id)
draft_url = "https://reviews.apache.org/api/review-requests/" + rb_id + "/draft/"
diff_url = draft_url + "diffs/"
files = {'path' : (patch_filename, open(patch_filepath, 'rb'))}
r = requests.post(diff_url, files = files, auth = rb_auth)
assert_status_code(r, 201, "uploading diff to review draft")
r = requests.put(draft_url, data = {"public" : True}, auth = rb_auth)
assert_status_code(r, 200, "publishing review request")