forked from winder/fix_versioner
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathrelease_fix_versioner.py
executable file
·307 lines (241 loc) · 12.6 KB
/
release_fix_versioner.py
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
#! /usr/bin/env python3
from git import Repo
import requests
import argparse
import re
import json
import sys
from datetime import datetime
def parse_args():
""" Parse args and perform additional validation/initialization of computed arguments. """
parser = argparse.ArgumentParser()
parser.add_argument('--app', required=False, help='Automates setting "--release-tag <app>-<timestamp>" and "--previous-tag <app>-*". ')
parser.add_argument('--repo-path', required=True, help='Path to the repo being released.')
parser.add_argument('--release-name', help='Release name to use in Jira, if not provided the --release-tag is used, or if --app is present a computed <app>-<date> is used.')
parser.add_argument('--release-description', required=False, help='Optional description field used for the fix version.')
parser.add_argument('--release-tag', required=False, help='Most recent tag for this release, if wildcards are included will use the most recent match. If not present the most recent commit will be used.')
parser.add_argument('--previous-tag', required=False, help='Tag for the last release, if wildcards are included will use the most recent match. It is required if --app is not specified.')
parser.add_argument('--jira-base-url', required=True, help='Root of your jira URL, like \'https://traackr.atlassian.net\'')
parser.add_argument('--jira-username', required=True, help='Jira username.')
parser.add_argument('--jira-password', required=True, help='Jira password.')
parser.add_argument('--jira-project', required=True, default='CORE', help='Project to create fix version in.')
parser.add_argument('--commit-pattern', default='^(?P<key>[\w]*-[\d]*)[ :-](?P<value>.*)', help='Regex pattern used to group commits, <key> and <value> identifiers may be used to specify group order. For example: \'(?P<key>CORE-[\d]*): (?P<value>.*)\' or \'(CORE-[\d]*: (.*)\' could be used for CORE.')
parser.add_argument('--assume-yes', default=False, action='store_true', help='If prompted to continue, assume yes (i.e. there were invalid tickets, would you like to continue tagging valid tickets?).')
parser.add_argument('--allow-multiple-versions', default=False, action='store_true', help='For some issues there may be changes in multiple applications, if you want a fix version per app use this flag.')
parser.add_argument('--create-tag', required=False, action='store_true', help='Causes a new tag to be created using the value of --app and a timestamp.')
parser.add_argument('--dry-run', default=True, type=str2bool, help='Do not modify any data.')
args = parser.parse_args()
if args.app and args.release_tag:
parser.error('Ambiguous release tag, must not provide --app and --release-tag.')
if not args.app and not args.previous_tag:
parser.error('One of --previous-tag or --app is required.')
# Initialize tags for app.
if args.app:
# Timestamp format yyyy-mm-ddThh.mm.ssZ
args.release_name = args.release_name or '%s-%s' % (args.app, datetime.now().strftime('%Y-%m-%dT%H.%M.%SZ'))
args.previous_tag = args.previous_tag or '%s-*' % args.app
if not args.release_name:
parser.error('Missing required parameter, --app or --release-name must be provided.')
return args
# https://stackoverflow.com/a/43357954/204023
def str2bool(v):
if v.lower() in ('yes', 'true', 't', 'y', '1'):
return True
elif v.lower() in ('no', 'false', 'f', 'n', '0'):
return False
else:
raise argparse.ArgumentTypeError('Boolean value expected.')
# http://stackoverflow.com/a/4690655/204023
def query_yes_no(question, default="yes"):
"""Ask a yes/no question via input() and return their answer.
"question" is a string that is presented to the user.
"default" is the presumed answer if the user just hits <Enter>.
It must be "yes" (the default), "no" or None (meaning
an answer is required of the user).
The "answer" return value is True for "yes" or False for "no".
"""
valid = {"yes": True, "y": True, "ye": True,
"no": False, "n": False}
if default is None:
prompt = " [y/n] "
elif default == "yes":
prompt = " [Y/n] "
elif default == "no":
prompt = " [y/N] "
else:
raise ValueError("invalid default answer: '%s'" % default)
while True:
print(question + prompt, end='')
choice = input().lower()
if default is not None and choice == '':
return valid[default]
elif choice in valid:
return valid[choice]
else:
print("Please respond with 'yes' or 'no' (or 'y' or 'n').\n", end='')
def get_commits_for_tag(repo, first_tag, second_tag):
""" Return commits between the two tags using 'git log first...second' to fetch commits. """
# --format=%s for ease of parsing.
commits = repo.git.log('--format=%s', '{}...{}'.format(first_tag, second_tag))
commits = commits.split('\n')
return commits
def group_commits_by_pattern(regex_pattern, commit_messages):
""" Group commits according to a pattern. In case pattern is not formatted 'key: message' group ids may be used. """
key_id = 1
value_id = 2
if 'key' in regex_pattern.groupindex:
key_id = regex_pattern.groupindex['key']
if 'value' in regex_pattern.groupindex:
value_id = regex_pattern.groupindex['value']
commit_dict = {}
unknown_commits = []
for commit in commit_messages:
# parse out JIRA id
m = re.search(regex_pattern, commit)
if m:
key = m.group(key_id).strip().upper()
if key not in commit_dict:
commit_dict[key] = []
commit_dict[key].append(m.group(value_id).strip())
else:
unknown_commits.append(commit.strip())
return commit_dict, unknown_commits
def validate_jira_id(jira_id, allow_multiple_fix_versions, base_url, jira_username, jira_password):
""" Throw an exception if the jira_id is invalid. Check jira to verify existence, done state and fix version. """
request_url = base_url + '/rest/api/2/issue/' + jira_id
response = requests.get(request_url, auth=(jira_username, jira_password))
if response.status_code == 404:
message = 'Jira ticket not found.'
raise ValueError(message)
if response.status_code != 200:
message = 'Unexpected status code during lookup: {}'.format(response.status_code)
raise ValueError(message)
# parse response and check fields
issue_json = json.loads(response.text)
if issue_json['fields']['status']['name'] != 'Done':
message = "Invalid status, expected 'Done' found '{}'".format(issue_json['fields']['status']['name'])
raise ValueError(message)
if not allow_multiple_fix_versions and 'fixVersions' in issue_json['fields'] and len(issue_json['fields']['fixVersions']) != 0:
message = 'Already contains a fix version: {}'.format(issue_json['fields']['fixVersions'][0]['name'])
raise ValueError(message)
def create_fix_version(fix_version_name, description, jira_project, base_url, jira_username, jira_password):
""" Create a fix version in given project with given name, returns the fix version id or raises an exception. """
post_data = {
'name': fix_version_name,
'project': jira_project,
'released': True,
'userReleaseDate': datetime.now().strftime('%d/%B/%Y')
}
if description:
post_data['description'] = description
response = requests.post(
base_url + '/rest/api/2/version',
headers={'content-type': 'application/json'},
auth=(jira_username, jira_password),
data=json.dumps(post_data))
if response.status_code is not 201:
raise ValueError('Failed to create fix version. Expected status 201, received {}'.format(response.status_code))
status_response_json = json.loads(response.text)
return status_response_json['id']
def add_fix_version_to_ticket(jira_id, fix_version_id, base_url, jira_username, jira_password):
""" Add fix version to jira ticket. """
post_data = {
'update': {
'fixVersions': [
{
'add': {
'id': fix_version_id
}
}
]
}
}
response = requests.put(
base_url + '/rest/api/2/issue/' + jira_id,
headers={'content-type': 'application/json'},
auth=(jira_username, jira_password),
data=json.dumps(post_data))
if response.status_code is not 204:
raise ValueError('Failed to add fix version to {}. Expected status 204, received {}'.format(jira_id, response.status_code))
def resolve_tag(repo, tag):
""" Given a tag with optional wildcards, returns the most recent match. """
if not tag:
return ""
# Find tags
tags = repo.tags
sorted_tags = sorted(tags, key=lambda tag_to_sort: tag_to_sort.commit.committed_date, reverse=True)
p = re.compile(tag)
for t in sorted_tags:
if p.match(t.name):
return t
raise Exception('Could not find tag: %s' % tag)
def main():
args = parse_args()
repo = Repo.init(args.repo_path)
pattern = re.compile(args.commit_pattern)
# Grab commits
release = resolve_tag(repo, args.release_tag)
previous = resolve_tag(repo, args.previous_tag)
print('\nLooking up unique issues in range: {} ... {}'.format(release, previous))
commits = get_commits_for_tag(repo, release, previous)
# Group by pattern
grouped_commits, unknown_commits = group_commits_by_pattern(pattern, commits)
# Sort by number of commits for fun
# my_sorted = sorted(grouped_commits.items(), key=lambda item: len(item[1]), reverse=True)
# for key, value in my_sorted:
# print('{}, {} commits: {}'.format(key, len(value), value))
# is_valid_jira_id(key, args.jira_username, args.jira_password)
print('\n=======================================================')
print('= Came across some commits which couldn\'t be parsed: =')
print('=======================================================\n')
for commit in unknown_commits:
print(commit)
valid_tickets = {}
invalid_tickets = {}
# Validate tickets:
print('\nValidating {} jira tickets: {}'.format(len(grouped_commits), ', '.join(grouped_commits.keys())))
for jira_id, commit_list in grouped_commits.items():
try:
validate_jira_id(jira_id, args.allow_multiple_versions, args.jira_base_url, args.jira_username, args.jira_password)
valid_tickets[jira_id] = commit_list
except ValueError as e:
invalid_tickets[jira_id] = (commit_list, str(e))
if len(valid_tickets) != 0:
print('\nThe following valid tickets were discovered:\n')
for ticketId, commits in valid_tickets.items():
print('{} is ready to tag with {} commits.'.format(ticketId, len(commits)))
if len(invalid_tickets) != 0:
print('\n=================================================')
print('= The following invalid tickets were discovered =')
print('=================================================\n')
# Sort by error message:
for ticketId, commitErrorTuple in sorted(invalid_tickets.items(), key=lambda item: item[1][1]):
print('%s is invalid: %s' % (ticketId, commitErrorTuple[1]))
print('%s/browse/%s' % (args.jira_base_url, ticketId))
print('')
# Early exit.
if len(valid_tickets) == 0:
print('There were no valid tickets to release, exiting.')
sys.exit(-1)
if args.dry_run:
print('Done releasing {}! (dry run exit)'.format(args.release_name))
sys.exit(-1)
if args.assume_yes is False and not query_yes_no('Would you like to continue tagging valid tickets?'):
sys.exit(-1)
# Create fix version.
fix_version_id = create_fix_version(args.release_name, args.release_description, args.jira_project, args.jira_base_url, args.jira_username, args.jira_password)
# Apply fix version to tickets.
for item in valid_tickets.items():
try:
add_fix_version_to_ticket(item[0], fix_version_id, args.jira_base_url, args.jira_username, args.jira_password)
except ValueError as e:
print('Failure setting fix version for {}: {}'.format(item[0], str(e)))
# Create and push a tag if requested
if args.create_tag and valid_tickets:
new_tag = repo.create_tag(args.release_name, message='Automated tag.')
repo.remotes.origin.push(new_tag)
# Release notes summary?
print('Done releasing {}!'.format(args.release_name))
# Kick off the main function
if __name__ == '__main__':
main()