blob: 5e0a126140f3fbf65d57a356849e9fb491b489ba [file] [log] [blame]
Charles Hoferac551212016-10-20 16:33:41 -05001#!/usr/bin/python
2
3##
4# Copyright c 2016 IBM Corporation
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10# http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17##
18
19###############################################################################
20# @file commit-tracker
21# @brief Prints out all commits on the master branch of the specified
22# repository, as well as all commits on linked submodule
23# repositories
24###############################################################################
25
Charles Hofer046fc912016-11-10 10:42:13 -060026import argparse
Charles Hoferff54ae92016-11-29 16:22:39 -060027import git
Charles Hofera9e83c42016-11-29 14:15:24 -060028import json
Charles Hoferac551212016-10-20 16:33:41 -050029import logging
30import os
31import re
Charles Hoferff54ae92016-11-29 16:22:39 -060032import requests
Charles Hoferac551212016-10-20 16:33:41 -050033import sys
34import time
Charles Hoferac551212016-10-20 16:33:41 -050035
Charles Hofera9e83c42016-11-29 14:15:24 -060036class CommitReportEncoder(json.JSONEncoder):
37 def default(self, i_obj):
38 return i_obj.__dict__
39
Charles Hoferac551212016-10-20 16:33:41 -050040###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -060041# @class CommitReport
42# @brief A class representing information about a commit and all commits in
43# relevant subrepos
44###############################################################################
45class CommitReport:
46 def __init__(self, i_repo_uri, i_repo_name, i_sha, i_nice_name,
Charles Hoferff54ae92016-11-29 16:22:39 -060047 i_summary, i_insertions, i_deletions, i_closed_issues):
Charles Hofer493cd6f2016-11-29 15:54:17 -060048 self.repo_uri = i_repo_uri
49 self.repo_name = i_repo_name
50 self.sha = i_sha
51 self.nice_name = i_nice_name
52 self.summary = i_summary
53 self.insertions = i_insertions
54 self.deletions = i_deletions
Charles Hoferff54ae92016-11-29 16:22:39 -060055 self.closed_issues = i_closed_issues
Charles Hofer493cd6f2016-11-29 15:54:17 -060056 self.subreports = []
57
58 def to_cl_string(self, i_level=0):
59 # Define colors for the console
60 RED = '\033[31m'
61 BLUE = '\033[94m'
62 ENDC = '\033[0m'
63 # Put the string together
64 l_cl_string = (' ' * i_level) + RED + self.repo_name + ENDC + ' ' \
65 + BLUE + self.nice_name + ENDC + ' ' \
66 + re.sub('\s+', ' ', self.summary)
67 # Do the same for every subreport
68 for l_report in self.subreports:
69 l_cl_string += '\n' + l_report.to_cl_string(i_level + 1)
70 return l_cl_string
71
Charles Hoferec6b0412016-11-29 14:15:24 -060072 def to_html(self, i_level=0):
73 l_repo_url = re.sub('git://', 'http://', self.repo_uri)
74 # Get HTML for this commit
75 l_html = \
76 '<div style="margin-left: ' + str(i_level * 20) + 'px">' \
77 + '<a href="' + l_repo_url + '" target="_blank" ' \
78 + 'style="color: red">' + self.repo_name + '</a>&nbsp;' \
79 + '<a href="' + l_repo_url + '/commit/' + self.sha \
80 + '" target="_blank" style="color: blue">' + self.nice_name \
81 + '</a>&nbsp;' \
82 + '<span>' + re.sub('\s+', ' ', self.summary) + '</span>' \
83 + '</div>\n'
84 # Get the HTML for all subcommits
85 for l_commit in self.subreports:
86 l_html += l_commit.to_html(i_level + 1)
87 return l_html
88
Charles Hofer493cd6f2016-11-29 15:54:17 -060089 def get_total_insertions(self):
90 l_insertions = self.insertions
91 for l_commit in self.subreports:
92 l_insertions += l_commit.get_total_insertions()
93 return l_insertions
94
95 def get_total_deletions(self):
96 l_deletions = self.deletions
97 for l_commit in self.subreports:
98 l_deletions += l_commit.get_total_deletions()
99 return l_deletions
100
Charles Hoferff54ae92016-11-29 16:22:39 -0600101 def get_all_closed_issues(self):
102 l_closed_issues = self.closed_issues
103 for l_commit in self.subreports:
104 l_closed_issues.extend(l_commit.get_all_closed_issues())
105 return l_closed_issues
106
Charles Hofer493cd6f2016-11-29 15:54:17 -0600107###############################################################################
Charles Hoferac551212016-10-20 16:33:41 -0500108# @brief Main function for the script
109#
110# @param i_args : Command line arguments
111###############################################################################
112def main(i_args):
Charles Hofer046fc912016-11-10 10:42:13 -0600113 # Parse the arguments
Charles Hoferec6b0412016-11-29 14:15:24 -0600114 l_args = parse_arguments(i_args)
Charles Hoferac551212016-10-20 16:33:41 -0500115
Charles Hoferec6b0412016-11-29 14:15:24 -0600116 # Set the logger level
117 logging.basicConfig(level=logging.ERROR)
118
119 # Generate the commit reports
120 print 'Getting report for ' + l_args.repo_dir
Charles Hofer493cd6f2016-11-29 15:54:17 -0600121 l_reports = generate_commit_reports(
Charles Hoferec6b0412016-11-29 14:15:24 -0600122 l_args.repo_uri,
123 l_args.repo_dir,
124 l_args.latest_commit,
125 l_args.earliest_commit)
Charles Hofer493cd6f2016-11-29 15:54:17 -0600126
127 # Compile issues, insertions, and deletions
Charles Hoferff54ae92016-11-29 16:22:39 -0600128 l_issues = []
Charles Hofer493cd6f2016-11-29 15:54:17 -0600129 l_total_deletions = 0
130 l_total_insertions = 0
131 for l_report in l_reports:
132 l_total_deletions += l_report.get_total_deletions()
133 l_total_insertions += l_report.get_total_insertions()
Charles Hoferff54ae92016-11-29 16:22:39 -0600134 l_issues.extend(l_report.get_all_closed_issues())
Charles Hofer493cd6f2016-11-29 15:54:17 -0600135
136 # Print commit information to the console
Charles Hoferec6b0412016-11-29 14:15:24 -0600137 print 'Commits...'
Charles Hofer493cd6f2016-11-29 15:54:17 -0600138 for l_report in l_reports:
139 print l_report.to_cl_string()
Charles Hoferff54ae92016-11-29 16:22:39 -0600140 print 'Closed issues...'
141 for l_issue in l_issues:
142 print ' ' + str(l_issue[0]) + ' ' + str(l_issue[1])
Charles Hoferec6b0412016-11-29 14:15:24 -0600143 print 'Insertions and deletions...'
Charles Hofer493cd6f2016-11-29 15:54:17 -0600144 print str(l_total_insertions) + ' insertions'
145 print str(l_total_deletions) + ' deletions'
Charles Hoferac551212016-10-20 16:33:41 -0500146
Charles Hoferec6b0412016-11-29 14:15:24 -0600147 # Write to the HTML file if the user set the flag
148 if l_args.html_file:
149 print 'Writing to HTML file...'
150 l_html_file = open(l_args.html_file, 'w+')
151 l_html_file.write('<html><body>\n')
152 for l_report in l_reports:
153 l_html_file.write(l_report.to_html())
154 l_html_file.write('<p>' + str(l_total_insertions) \
155 + ' insertions and ' + str(l_total_deletions) \
156 + ' deletions</p>')
157 l_html_file.write('<div>Closed Issues</div>')
158 for l_issue in l_issues:
159 l_html_file.write('<div><a href="http://www.github.com/' \
160 + re.sub('#', '/issues/', l_issue[0]) \
161 + '" target="_blank">' + l_issue[0] + '</a> ' \
162 + l_issue[1] + '</div>')
163 l_html_file.write('</body></html>')
164 l_html_file.close()
165
Charles Hofera9e83c42016-11-29 14:15:24 -0600166 # Write to the JSON file if the user set the flag
167 if l_args.json_file:
168 print 'Writing to JSON file...'
169 l_json_file = open(l_args.json_file, 'w+')
170 l_json_file.write(CommitReportEncoder().encode(l_reports))
171 l_json_file.close()
172
Charles Hofer046fc912016-11-10 10:42:13 -0600173###############################################################################
174# @brief Parses the arguments from the command line
175#
176# @param i_args : The list of arguments from the command line, excluding the
177# name of the script
178#
179# @return An object representin the parsed arguments
180###############################################################################
181def parse_arguments(i_args):
182 l_parser = argparse.ArgumentParser(
183 description='Prints commit information from the given repo and all ' \
184 +'sub-repos specified with SRC_REV, starting from the ' \
185 +'most recent commit specified going back to the ' \
186 +'earliest commit specified.')
187 l_parser.add_argument(
Charles Hofer493cd6f2016-11-29 15:54:17 -0600188 'repo_uri',
189 help='The URI of the repo to get commit information for')
190 l_parser.add_argument(
Charles Hofer046fc912016-11-10 10:42:13 -0600191 'repo_dir',
192 help='The directory of the repo to get commit information for')
193 l_parser.add_argument(
194 'latest_commit',
195 help='A reference (branch name, HEAD, SHA, etc.) to the most ' \
196 +'recent commit to get information for')
197 l_parser.add_argument(
198 'earliest_commit',
199 help='A reference to the earliest commit to get information for')
Charles Hoferec6b0412016-11-29 14:15:24 -0600200 l_parser.add_argument(
201 '--html_file',
202 default=None,
203 help='If set to a file path, this script will write an HTML ' \
204 +'version of the console output to the file path given')
Charles Hofera9e83c42016-11-29 14:15:24 -0600205 l_parser.add_argument(
206 '--json_file',
207 default=None,
208 help='If set to a file path, this script will write a JSON version ' \
209 +'of the generated report to the file path given')
Charles Hofer046fc912016-11-10 10:42:13 -0600210 return l_parser.parse_args(i_args)
Charles Hoferac551212016-10-20 16:33:41 -0500211
212###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600213# @brief Generates a list of CommitReport objects, each one
214# representing a commit in the given repo URI and path,
215# starting at the beginning commit inclusive, ending at the
216# end commit exclusive
Charles Hoferac551212016-10-20 16:33:41 -0500217#
Charles Hofer493cd6f2016-11-29 15:54:17 -0600218# @param i_repo_uri : The URI to the repo to get reports for
219# @param i_repo_path : The path to the repo to get reports for
220# @param i_begin_commit : A reference to the most recent commit. The
221# most recent commit to get a report for
Charles Hoferac551212016-10-20 16:33:41 -0500222# @param i_end_commit : A reference to the commit farthest in the
Charles Hofer493cd6f2016-11-29 15:54:17 -0600223# past. The next youngest commit will be
224# the last one to get a report for
225#
226# @return A list of CommitReport objects in order from newest to
227# oldest commit
Charles Hoferac551212016-10-20 16:33:41 -0500228###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600229def generate_commit_reports(i_repo_uri, i_repo_path, i_begin_commit,
230 i_end_commit):
231 # Get the repo that the user requested
232 clone_or_update(i_repo_uri, i_repo_path)
Charles Hoferac551212016-10-20 16:33:41 -0500233 try:
234 l_repo = git.Repo(i_repo_path)
235 except git.exc.InvalidGitRepositoryError:
236 logging.error(str(i_repo_path) + ' is not a valid git repository')
237 return
238
239 # Get commits between the beginning and end references
240 try:
241 l_commits = l_repo.iter_commits(rev=(i_begin_commit + '...'
Charles Hofer493cd6f2016-11-29 15:54:17 -0600242 + i_end_commit))
243 # Go through each commit, generating a report
244 l_reports = []
Charles Hoferac551212016-10-20 16:33:41 -0500245 for l_commit in l_commits:
Charles Hofer493cd6f2016-11-29 15:54:17 -0600246 # Get the insertion and deletion line counts
247 l_insertions, l_deletions = get_line_count(
248 l_repo,str(l_commit.hexsha),
249 str(l_commit.hexsha) + '~1')
250 # Construct a new commit report
251 l_report = CommitReport(
252 i_repo_uri,
253 i_repo_path.split('/')[-1].replace('.git', ''),
254 str(l_commit.hexsha),
255 to_prefix_name_rev(l_commit.name_rev),
256 l_commit.summary,
257 l_insertions,
Charles Hoferff54ae92016-11-29 16:22:39 -0600258 l_deletions,
259 get_closed_issues(l_commit))
Charles Hofer493cd6f2016-11-29 15:54:17 -0600260
Charles Hoferac551212016-10-20 16:33:41 -0500261 # Search the diffs for any bumps of submodule versions
262 l_diffs = l_commit.diff(str(l_commit.hexsha) + '~1')
263 for l_diff in l_diffs:
264 # If we have two files to compare with diff...
265 if l_diff.a_path and l_diff.b_path:
266 # ... get info about the change, log it...
267 l_subrepo_uri, l_subrepo_new_hash, l_subrepo_old_hash \
Charles Hofer493cd6f2016-11-29 15:54:17 -0600268 = get_bump_info(l_repo, str(l_commit.hexsha),
269 i_repo_path, l_diff.b_path)
Charles Hoferac551212016-10-20 16:33:41 -0500270 logging.debug('Found diff...')
271 logging.debug(' Subrepo URI: ' + str(l_subrepo_uri))
272 logging.debug(' Subrepo new hash: '
273 + str(l_subrepo_new_hash))
274 logging.debug(' Subrepo old hash: '
275 + str(l_subrepo_old_hash))
Charles Hofer493cd6f2016-11-29 15:54:17 -0600276 logging.debug(' Found in: ' + str(l_diff.b_path))
Charles Hoferac551212016-10-20 16:33:41 -0500277 # ... and print the commits for the subrepo if this was a
278 # version bump
Charles Hofera9e83c42016-11-29 14:15:24 -0600279 if l_subrepo_new_hash \
280 and l_subrepo_old_hash \
281 and l_subrepo_uri \
282 and l_subrepo_uri.startswith('git'):
Charles Hoferac551212016-10-20 16:33:41 -0500283 logging.debug(' Bumped')
284 l_subrepo_path = l_subrepo_uri.split('/')[-1]
Charles Hofer493cd6f2016-11-29 15:54:17 -0600285 l_subreports = generate_commit_reports(
286 l_subrepo_uri,
287 l_subrepo_path,
288 l_subrepo_new_hash,
289 l_subrepo_old_hash)
290 l_report.subreports.extend(l_subreports)
291
292 # Put the report on the end of the list
293 l_reports.append(l_report)
294
295 except git.exc.GitCommandError as e:
296 logging.error(e)
Charles Hoferac551212016-10-20 16:33:41 -0500297 logging.error(str(i_begin_commit) + ' and ' + str(i_end_commit)
298 + ' are invalid revisions')
Charles Hofer493cd6f2016-11-29 15:54:17 -0600299 return l_reports
Charles Hoferac551212016-10-20 16:33:41 -0500300
301###############################################################################
302# @brief Gets the repo URI, the updated SHA, and the old SHA from a
303# given repo, commit SHA and file
Charles Hofera9e83c42016-11-29 14:15:24 -0600304#
Charles Hoferac551212016-10-20 16:33:41 -0500305# @param i_repo : The Repo object to get version bump information
306# from
Charles Hoferac551212016-10-20 16:33:41 -0500307# @param i_hexsha : The hex hash for the commit to search for
308# version bumps
Charles Hofer493cd6f2016-11-29 15:54:17 -0600309# @param i_repo_path : The path to the repo containing the file to
310# get bump information from
311# @param i_file : The path, starting at the base of the repo,
312# to the file to get bump information from
Charles Hoferac551212016-10-20 16:33:41 -0500313#
Charles Hofera9e83c42016-11-29 14:15:24 -0600314# @return Returns the repo URI, the updatedS SHA, and the old SHA in
Charles Hoferac551212016-10-20 16:33:41 -0500315# a tuple in that order
316###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600317def get_bump_info(i_repo, i_hexsha, i_repo_path, i_file):
Charles Hoferac551212016-10-20 16:33:41 -0500318 # Checkout the old repo
319 i_repo.git.checkout(i_hexsha)
320 # Get the diff text
321 l_diff_text = i_repo.git.diff(i_hexsha, i_hexsha + '~1', '--', i_file)
Charles Hofer493cd6f2016-11-29 15:54:17 -0600322 logging.debug('Hash: ' + i_hexsha)
323 logging.debug('File: ' + i_repo_path + '/' + i_file)
324 logging.debug('Diff Text: ' + l_diff_text)
Charles Hoferac551212016-10-20 16:33:41 -0500325
Charles Hofera9e83c42016-11-29 14:15:24 -0600326 # Get the new and old version hashes
Charles Hoferac551212016-10-20 16:33:41 -0500327 l_old_hash = None
328 l_new_hash = None
Charles Hofer493cd6f2016-11-29 15:54:17 -0600329 l_old_hash_match = re.search('-[A-Z_]*SRCREV[+=? ]+"([a-f0-9]+)"',
330 l_diff_text)
331 l_new_hash_match = re.search('\+[A-Z_]*SRCREV[+=? ]+"([a-f0-9]+)"',
332 l_diff_text)
Charles Hoferac551212016-10-20 16:33:41 -0500333 if l_old_hash_match:
334 l_old_hash = l_old_hash_match.group(1)
335 if l_new_hash_match:
336 l_new_hash = l_new_hash_match.group(1)
337
338 # Get the URI of the subrepo
339 l_uri = None
Charles Hofer493cd6f2016-11-29 15:54:17 -0600340 if os.path.isfile(i_repo_path + '/' + i_file):
341 l_changed_file = open(i_repo_path + '/' + i_file, 'r')
Charles Hoferac551212016-10-20 16:33:41 -0500342 for l_line in l_changed_file:
Charles Hoferac551212016-10-20 16:33:41 -0500343 l_uri_match = re.search('_URI[+=? ]+"([-a-zA-Z0-9/:\.]+)"', l_line)
344 if l_uri_match:
345 l_uri = l_uri_match.group(1)
346 break
Charles Hofer493cd6f2016-11-29 15:54:17 -0600347 else:
348 logging.debug(i_repo_path + '/' + i_file)
Charles Hoferac551212016-10-20 16:33:41 -0500349
350 # Go back to master
351 i_repo.git.checkout('master')
352 return l_uri, l_new_hash, l_old_hash
353
354###############################################################################
355# @brief Updates the repo under the given path or clones it from the
356# uri if it doesn't yet exist
357#
358# @param i_uri : The URI to the remote repo to clone
359# @param i_path : The file path to where the repo currently exists or
360# where it will be created
361###############################################################################
362def clone_or_update(i_uri, i_path):
363 # If the repo exists, just update it
364 if os.path.isdir(i_path):
365 l_repo = git.Repo(i_path)
366 l_repo.remotes[0].pull()
367
368 # If it doesn't exist, clone it
369 else:
370 os.mkdir(i_path)
371 l_repo = git.Repo.init(i_path)
372 origin = l_repo.create_remote('origin', i_uri)
373 origin.fetch()
374 l_repo.create_head('master', origin.refs.master) \
375 .set_tracking_branch(origin.refs.master)
376 origin.pull()
377
378###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600379# @brief Gets the number of changed lines between two commits
Charles Hoferac551212016-10-20 16:33:41 -0500380#
Charles Hofer493cd6f2016-11-29 15:54:17 -0600381# @param i_repo : The Repo object these commits are in
382# @param i_begin_commit : A git reference to the beginning commit
383# @param i_end_commit : A git reference to the end commit
384#
385# @return A two-tuple containing the number of insertions and the number of
386# deletions between the begin and end commit
Charles Hoferac551212016-10-20 16:33:41 -0500387###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600388def get_line_count(i_repo, i_begin_commit, i_end_commit):
389 diff_output = i_repo.git.diff(i_end_commit, i_begin_commit, shortstat=True)
390 insertions = 0
391 deletions = 0
392 insertion_match = re.search('([0-9]+) insertion', diff_output)
393 deletion_match = re.search('([0-9]+) deletion', diff_output)
394 if insertion_match:
395 insertions = int(insertion_match.group(1))
396 if deletion_match:
397 deletions = int(deletion_match.group(1))
398 return insertions, deletions
Charles Hoferac551212016-10-20 16:33:41 -0500399
Charles Hoferff54ae92016-11-29 16:22:39 -0600400###############################################################################
401# @brief Gets closed issues from the commit message
402#
403# @param i_commit : The commit to get closed issues for
404#
405# @return A list of tuples, the first element being the ID of the issue, the
406# second being the title from GitHub
407###############################################################################
408def get_closed_issues(i_commit):
409 l_closed_issues = []
410
411 # Set up the regex
412 l_close_regex = re.compile(
413 '((F|f)ix((es|ed)?)|(C|c)lose((s|d)?)|(R|r)esolve((s|d)?)) '
414 + '+(?P<issue>[a-zA-Z0-9#]+\/[a-zA-Z0-9#]+)')
415 l_matches = l_close_regex.finditer(i_commit.message)
416
417 # Loop through all the matches getting each issue name
418 for l_match in l_matches:
419 l_issue_id = l_match.group('issue')
420 l_issue_title = get_issue_title(l_issue_id)
421 l_closed_issues.append((l_issue_id, l_issue_title))
422
423 return l_closed_issues
424
425###############################################################################
426# @brief Gets the title of an issue based on the issue ID
427#
428# @param i_issue_id : The ID of the issue to get the title for
429#
430# @return The title of the issue
431###############################################################################
432def get_issue_title(i_issue_id):
433 # Construct the URL
434 l_url_tail = re.sub('#', '/issues/', i_issue_id)
435 l_full_url = 'https://api.github.com/repos/' + l_url_tail
436 l_title = ''
437
438 # Send in the web request
439 l_response = requests.get(l_full_url)
440 if 200 == l_response.status_code:
441 l_issue = l_response.json()
442 l_title = l_issue['title']
443 else:
444 logging.error(l_response.text)
445 logging.error('Recieved status code ' \
446 + str(l_response.status_code) \
447 + ' when getting issue titles.')
448 return l_title
449
Charles Hofer493cd6f2016-11-29 15:54:17 -0600450##############################################################################
451# @brief Cuts the hash in commit revision names down to its 7 digit prefix
452#
453# @param i_name_rev : The name of the revision to change
454#
455# @return The same revision name but with the hash its 7 digit prefix instead
456###############################################################################
457def to_prefix_name_rev(i_name_rev):
458 l_name_rev = i_name_rev
Charles Hoferac551212016-10-20 16:33:41 -0500459 l_hash, l_name = l_name_rev.split()
Charles Hofer493cd6f2016-11-29 15:54:17 -0600460 l_name_rev = l_hash[0:7] + ' ' + l_name
461 return l_name_rev
Charles Hoferac551212016-10-20 16:33:41 -0500462
Charles Hoferac551212016-10-20 16:33:41 -0500463# Only run main if run as a script
464if __name__ == '__main__':
465 main(sys.argv[1:])
466