blob: b3939842673c83ea67b271c5b950d24d2d1fcf50 [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 Hoferac551212016-10-20 16:33:41 -050028import logging
29import os
30import re
Charles Hoferff54ae92016-11-29 16:22:39 -060031import requests
Charles Hoferac551212016-10-20 16:33:41 -050032import sys
33import time
Charles Hoferac551212016-10-20 16:33:41 -050034
35###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -060036# @class CommitReport
37# @brief A class representing information about a commit and all commits in
38# relevant subrepos
39###############################################################################
40class CommitReport:
41 def __init__(self, i_repo_uri, i_repo_name, i_sha, i_nice_name,
Charles Hoferff54ae92016-11-29 16:22:39 -060042 i_summary, i_insertions, i_deletions, i_closed_issues):
Charles Hofer493cd6f2016-11-29 15:54:17 -060043 self.repo_uri = i_repo_uri
44 self.repo_name = i_repo_name
45 self.sha = i_sha
46 self.nice_name = i_nice_name
47 self.summary = i_summary
48 self.insertions = i_insertions
49 self.deletions = i_deletions
Charles Hoferff54ae92016-11-29 16:22:39 -060050 self.closed_issues = i_closed_issues
Charles Hofer493cd6f2016-11-29 15:54:17 -060051 self.subreports = []
52
53 def to_cl_string(self, i_level=0):
54 # Define colors for the console
55 RED = '\033[31m'
56 BLUE = '\033[94m'
57 ENDC = '\033[0m'
58 # Put the string together
59 l_cl_string = (' ' * i_level) + RED + self.repo_name + ENDC + ' ' \
60 + BLUE + self.nice_name + ENDC + ' ' \
61 + re.sub('\s+', ' ', self.summary)
62 # Do the same for every subreport
63 for l_report in self.subreports:
64 l_cl_string += '\n' + l_report.to_cl_string(i_level + 1)
65 return l_cl_string
66
67 def get_total_insertions(self):
68 l_insertions = self.insertions
69 for l_commit in self.subreports:
70 l_insertions += l_commit.get_total_insertions()
71 return l_insertions
72
73 def get_total_deletions(self):
74 l_deletions = self.deletions
75 for l_commit in self.subreports:
76 l_deletions += l_commit.get_total_deletions()
77 return l_deletions
78
Charles Hoferff54ae92016-11-29 16:22:39 -060079 def get_all_closed_issues(self):
80 l_closed_issues = self.closed_issues
81 for l_commit in self.subreports:
82 l_closed_issues.extend(l_commit.get_all_closed_issues())
83 return l_closed_issues
84
Charles Hofer493cd6f2016-11-29 15:54:17 -060085###############################################################################
Charles Hoferac551212016-10-20 16:33:41 -050086# @brief Main function for the script
87#
88# @param i_args : Command line arguments
89###############################################################################
90def main(i_args):
Charles Hofer046fc912016-11-10 10:42:13 -060091 # Parse the arguments
92 l_args_obj = parse_arguments(i_args)
Charles Hoferac551212016-10-20 16:33:41 -050093
Charles Hofer046fc912016-11-10 10:42:13 -060094 # Print every commit
Charles Hofer493cd6f2016-11-29 15:54:17 -060095 print 'Getting report for ' + l_args_obj.repo_dir
96 l_reports = generate_commit_reports(
97 l_args_obj.repo_uri,
98 l_args_obj.repo_dir,
99 l_args_obj.latest_commit,
100 l_args_obj.earliest_commit)
101
102 # Compile issues, insertions, and deletions
Charles Hoferff54ae92016-11-29 16:22:39 -0600103 l_issues = []
Charles Hofer493cd6f2016-11-29 15:54:17 -0600104 l_total_deletions = 0
105 l_total_insertions = 0
106 for l_report in l_reports:
107 l_total_deletions += l_report.get_total_deletions()
108 l_total_insertions += l_report.get_total_insertions()
Charles Hoferff54ae92016-11-29 16:22:39 -0600109 l_issues.extend(l_report.get_all_closed_issues())
Charles Hofer493cd6f2016-11-29 15:54:17 -0600110
111 # Print commit information to the console
112 print 'Commits'
113 for l_report in l_reports:
114 print l_report.to_cl_string()
Charles Hoferff54ae92016-11-29 16:22:39 -0600115 print 'Closed issues...'
116 for l_issue in l_issues:
117 print ' ' + str(l_issue[0]) + ' ' + str(l_issue[1])
Charles Hofer493cd6f2016-11-29 15:54:17 -0600118 print 'Insertions and Deletions'
119 print str(l_total_insertions) + ' insertions'
120 print str(l_total_deletions) + ' deletions'
Charles Hoferac551212016-10-20 16:33:41 -0500121
Charles Hofer046fc912016-11-10 10:42:13 -0600122###############################################################################
123# @brief Parses the arguments from the command line
124#
125# @param i_args : The list of arguments from the command line, excluding the
126# name of the script
127#
128# @return An object representin the parsed arguments
129###############################################################################
130def parse_arguments(i_args):
131 l_parser = argparse.ArgumentParser(
132 description='Prints commit information from the given repo and all ' \
133 +'sub-repos specified with SRC_REV, starting from the ' \
134 +'most recent commit specified going back to the ' \
135 +'earliest commit specified.')
136 l_parser.add_argument(
Charles Hofer493cd6f2016-11-29 15:54:17 -0600137 'repo_uri',
138 help='The URI of the repo to get commit information for')
139 l_parser.add_argument(
Charles Hofer046fc912016-11-10 10:42:13 -0600140 'repo_dir',
141 help='The directory of the repo to get commit information for')
142 l_parser.add_argument(
143 'latest_commit',
144 help='A reference (branch name, HEAD, SHA, etc.) to the most ' \
145 +'recent commit to get information for')
146 l_parser.add_argument(
147 'earliest_commit',
148 help='A reference to the earliest commit to get information for')
149 return l_parser.parse_args(i_args)
Charles Hoferac551212016-10-20 16:33:41 -0500150
151###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600152# @brief Generates a list of CommitReport objects, each one
153# representing a commit in the given repo URI and path,
154# starting at the beginning commit inclusive, ending at the
155# end commit exclusive
Charles Hoferac551212016-10-20 16:33:41 -0500156#
Charles Hofer493cd6f2016-11-29 15:54:17 -0600157# @param i_repo_uri : The URI to the repo to get reports for
158# @param i_repo_path : The path to the repo to get reports for
159# @param i_begin_commit : A reference to the most recent commit. The
160# most recent commit to get a report for
Charles Hoferac551212016-10-20 16:33:41 -0500161# @param i_end_commit : A reference to the commit farthest in the
Charles Hofer493cd6f2016-11-29 15:54:17 -0600162# past. The next youngest commit will be
163# the last one to get a report for
164#
165# @return A list of CommitReport objects in order from newest to
166# oldest commit
Charles Hoferac551212016-10-20 16:33:41 -0500167###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600168def generate_commit_reports(i_repo_uri, i_repo_path, i_begin_commit,
169 i_end_commit):
170 # Get the repo that the user requested
171 clone_or_update(i_repo_uri, i_repo_path)
Charles Hoferac551212016-10-20 16:33:41 -0500172 try:
173 l_repo = git.Repo(i_repo_path)
174 except git.exc.InvalidGitRepositoryError:
175 logging.error(str(i_repo_path) + ' is not a valid git repository')
176 return
177
178 # Get commits between the beginning and end references
179 try:
180 l_commits = l_repo.iter_commits(rev=(i_begin_commit + '...'
Charles Hofer493cd6f2016-11-29 15:54:17 -0600181 + i_end_commit))
182 # Go through each commit, generating a report
183 l_reports = []
Charles Hoferac551212016-10-20 16:33:41 -0500184 for l_commit in l_commits:
Charles Hofer493cd6f2016-11-29 15:54:17 -0600185 # Get the insertion and deletion line counts
186 l_insertions, l_deletions = get_line_count(
187 l_repo,str(l_commit.hexsha),
188 str(l_commit.hexsha) + '~1')
189 # Construct a new commit report
190 l_report = CommitReport(
191 i_repo_uri,
192 i_repo_path.split('/')[-1].replace('.git', ''),
193 str(l_commit.hexsha),
194 to_prefix_name_rev(l_commit.name_rev),
195 l_commit.summary,
196 l_insertions,
Charles Hoferff54ae92016-11-29 16:22:39 -0600197 l_deletions,
198 get_closed_issues(l_commit))
Charles Hofer493cd6f2016-11-29 15:54:17 -0600199
Charles Hoferac551212016-10-20 16:33:41 -0500200 # Search the diffs for any bumps of submodule versions
201 l_diffs = l_commit.diff(str(l_commit.hexsha) + '~1')
202 for l_diff in l_diffs:
203 # If we have two files to compare with diff...
204 if l_diff.a_path and l_diff.b_path:
205 # ... get info about the change, log it...
206 l_subrepo_uri, l_subrepo_new_hash, l_subrepo_old_hash \
Charles Hofer493cd6f2016-11-29 15:54:17 -0600207 = get_bump_info(l_repo, str(l_commit.hexsha),
208 i_repo_path, l_diff.b_path)
Charles Hoferac551212016-10-20 16:33:41 -0500209 logging.debug('Found diff...')
210 logging.debug(' Subrepo URI: ' + str(l_subrepo_uri))
211 logging.debug(' Subrepo new hash: '
212 + str(l_subrepo_new_hash))
213 logging.debug(' Subrepo old hash: '
214 + str(l_subrepo_old_hash))
Charles Hofer493cd6f2016-11-29 15:54:17 -0600215 logging.debug(' Found in: ' + str(l_diff.b_path))
Charles Hoferac551212016-10-20 16:33:41 -0500216 # ... and print the commits for the subrepo if this was a
217 # version bump
218 if (l_subrepo_new_hash
219 and l_subrepo_old_hash
220 and l_subrepo_uri
221 and l_subrepo_uri.startswith('git')):
222 logging.debug(' Bumped')
223 l_subrepo_path = l_subrepo_uri.split('/')[-1]
Charles Hofer493cd6f2016-11-29 15:54:17 -0600224 l_subreports = generate_commit_reports(
225 l_subrepo_uri,
226 l_subrepo_path,
227 l_subrepo_new_hash,
228 l_subrepo_old_hash)
229 l_report.subreports.extend(l_subreports)
230
231 # Put the report on the end of the list
232 l_reports.append(l_report)
233
234 except git.exc.GitCommandError as e:
235 logging.error(e)
Charles Hoferac551212016-10-20 16:33:41 -0500236 logging.error(str(i_begin_commit) + ' and ' + str(i_end_commit)
237 + ' are invalid revisions')
Charles Hofer493cd6f2016-11-29 15:54:17 -0600238 return l_reports
Charles Hoferac551212016-10-20 16:33:41 -0500239
240###############################################################################
241# @brief Gets the repo URI, the updated SHA, and the old SHA from a
242# given repo, commit SHA and file
243#
244# @param i_repo : The Repo object to get version bump information
245# from
Charles Hoferac551212016-10-20 16:33:41 -0500246# @param i_hexsha : The hex hash for the commit to search for
247# version bumps
Charles Hofer493cd6f2016-11-29 15:54:17 -0600248# @param i_repo_path : The path to the repo containing the file to
249# get bump information from
250# @param i_file : The path, starting at the base of the repo,
251# to the file to get bump information from
Charles Hoferac551212016-10-20 16:33:41 -0500252#
253# @return Returns the repo URI, the updated SHA, and the old SHA in
254# a tuple in that order
255###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600256def get_bump_info(i_repo, i_hexsha, i_repo_path, i_file):
Charles Hoferac551212016-10-20 16:33:41 -0500257 # Checkout the old repo
258 i_repo.git.checkout(i_hexsha)
259 # Get the diff text
260 l_diff_text = i_repo.git.diff(i_hexsha, i_hexsha + '~1', '--', i_file)
Charles Hofer493cd6f2016-11-29 15:54:17 -0600261 logging.debug('Hash: ' + i_hexsha)
262 logging.debug('File: ' + i_repo_path + '/' + i_file)
263 logging.debug('Diff Text: ' + l_diff_text)
Charles Hoferac551212016-10-20 16:33:41 -0500264
265 # SRCREV sets the SHA for the version of the other repo to use when
266 # building openbmc. SHAs should be stored in the file in a format
267 # like SRCRV =? "<SHA>". Find both the new '+' and old '-' ones
268 l_old_hash = None
269 l_new_hash = None
Charles Hofer493cd6f2016-11-29 15:54:17 -0600270 l_old_hash_match = re.search('-[A-Z_]*SRCREV[+=? ]+"([a-f0-9]+)"',
271 l_diff_text)
272 l_new_hash_match = re.search('\+[A-Z_]*SRCREV[+=? ]+"([a-f0-9]+)"',
273 l_diff_text)
Charles Hoferac551212016-10-20 16:33:41 -0500274 if l_old_hash_match:
275 l_old_hash = l_old_hash_match.group(1)
276 if l_new_hash_match:
277 l_new_hash = l_new_hash_match.group(1)
278
279 # Get the URI of the subrepo
280 l_uri = None
Charles Hofer493cd6f2016-11-29 15:54:17 -0600281 if os.path.isfile(i_repo_path + '/' + i_file):
282 l_changed_file = open(i_repo_path + '/' + i_file, 'r')
Charles Hoferac551212016-10-20 16:33:41 -0500283 for l_line in l_changed_file:
284 # URIs should be stored in a format similar to
285 # SRC_URI ?= "git://github.com/<path to repo>"
286 l_uri_match = re.search('_URI[+=? ]+"([-a-zA-Z0-9/:\.]+)"', l_line)
287 if l_uri_match:
288 l_uri = l_uri_match.group(1)
289 break
Charles Hofer493cd6f2016-11-29 15:54:17 -0600290 else:
291 logging.debug(i_repo_path + '/' + i_file)
Charles Hoferac551212016-10-20 16:33:41 -0500292
293 # Go back to master
294 i_repo.git.checkout('master')
295 return l_uri, l_new_hash, l_old_hash
296
297###############################################################################
298# @brief Updates the repo under the given path or clones it from the
299# uri if it doesn't yet exist
300#
301# @param i_uri : The URI to the remote repo to clone
302# @param i_path : The file path to where the repo currently exists or
303# where it will be created
304###############################################################################
305def clone_or_update(i_uri, i_path):
306 # If the repo exists, just update it
307 if os.path.isdir(i_path):
308 l_repo = git.Repo(i_path)
309 l_repo.remotes[0].pull()
310
311 # If it doesn't exist, clone it
312 else:
313 os.mkdir(i_path)
314 l_repo = git.Repo.init(i_path)
315 origin = l_repo.create_remote('origin', i_uri)
316 origin.fetch()
317 l_repo.create_head('master', origin.refs.master) \
318 .set_tracking_branch(origin.refs.master)
319 origin.pull()
320
321###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600322# @brief Gets the number of changed lines between two commits
Charles Hoferac551212016-10-20 16:33:41 -0500323#
Charles Hofer493cd6f2016-11-29 15:54:17 -0600324# @param i_repo : The Repo object these commits are in
325# @param i_begin_commit : A git reference to the beginning commit
326# @param i_end_commit : A git reference to the end commit
327#
328# @return A two-tuple containing the number of insertions and the number of
329# deletions between the begin and end commit
Charles Hoferac551212016-10-20 16:33:41 -0500330###############################################################################
Charles Hofer493cd6f2016-11-29 15:54:17 -0600331def get_line_count(i_repo, i_begin_commit, i_end_commit):
332 diff_output = i_repo.git.diff(i_end_commit, i_begin_commit, shortstat=True)
333 insertions = 0
334 deletions = 0
335 insertion_match = re.search('([0-9]+) insertion', diff_output)
336 deletion_match = re.search('([0-9]+) deletion', diff_output)
337 if insertion_match:
338 insertions = int(insertion_match.group(1))
339 if deletion_match:
340 deletions = int(deletion_match.group(1))
341 return insertions, deletions
Charles Hoferac551212016-10-20 16:33:41 -0500342
Charles Hoferff54ae92016-11-29 16:22:39 -0600343###############################################################################
344# @brief Gets closed issues from the commit message
345#
346# @param i_commit : The commit to get closed issues for
347#
348# @return A list of tuples, the first element being the ID of the issue, the
349# second being the title from GitHub
350###############################################################################
351def get_closed_issues(i_commit):
352 l_closed_issues = []
353
354 # Set up the regex
355 l_close_regex = re.compile(
356 '((F|f)ix((es|ed)?)|(C|c)lose((s|d)?)|(R|r)esolve((s|d)?)) '
357 + '+(?P<issue>[a-zA-Z0-9#]+\/[a-zA-Z0-9#]+)')
358 l_matches = l_close_regex.finditer(i_commit.message)
359
360 # Loop through all the matches getting each issue name
361 for l_match in l_matches:
362 l_issue_id = l_match.group('issue')
363 l_issue_title = get_issue_title(l_issue_id)
364 l_closed_issues.append((l_issue_id, l_issue_title))
365
366 return l_closed_issues
367
368###############################################################################
369# @brief Gets the title of an issue based on the issue ID
370#
371# @param i_issue_id : The ID of the issue to get the title for
372#
373# @return The title of the issue
374###############################################################################
375def get_issue_title(i_issue_id):
376 # Construct the URL
377 l_url_tail = re.sub('#', '/issues/', i_issue_id)
378 l_full_url = 'https://api.github.com/repos/' + l_url_tail
379 l_title = ''
380
381 # Send in the web request
382 l_response = requests.get(l_full_url)
383 if 200 == l_response.status_code:
384 l_issue = l_response.json()
385 l_title = l_issue['title']
386 else:
387 logging.error(l_response.text)
388 logging.error('Recieved status code ' \
389 + str(l_response.status_code) \
390 + ' when getting issue titles.')
391 return l_title
392
Charles Hofer493cd6f2016-11-29 15:54:17 -0600393##############################################################################
394# @brief Cuts the hash in commit revision names down to its 7 digit prefix
395#
396# @param i_name_rev : The name of the revision to change
397#
398# @return The same revision name but with the hash its 7 digit prefix instead
399###############################################################################
400def to_prefix_name_rev(i_name_rev):
401 l_name_rev = i_name_rev
Charles Hoferac551212016-10-20 16:33:41 -0500402 l_hash, l_name = l_name_rev.split()
Charles Hofer493cd6f2016-11-29 15:54:17 -0600403 l_name_rev = l_hash[0:7] + ' ' + l_name
404 return l_name_rev
Charles Hoferac551212016-10-20 16:33:41 -0500405
Charles Hoferac551212016-10-20 16:33:41 -0500406# Only run main if run as a script
407if __name__ == '__main__':
408 main(sys.argv[1:])
409