1#!/usr/bin/env python3 2# Copyright (C) 2018 and later: Unicode, Inc. and others. 3# License & terms of use: http://www.unicode.org/copyright.html 4# Author: shane@unicode.org 5 6import argparse 7import itertools 8import os 9import re 10import sys 11 12from enum import Enum 13from collections import namedtuple 14from git import Repo 15from jira import JIRA 16 17 18ICUCommit = namedtuple("ICUCommit", ["issue_id", "commit"]) 19 20class CommitWanted(Enum): 21 REQUIRED = 1 22 OPTIONAL = 2 23 FORBIDDEN = 3 24 ERROR = 4 25 26ICUIssue = namedtuple("ICUIssue", ["issue_id", "is_closed", "commit_wanted", "issue"]) 27 28 29flag_parser = argparse.ArgumentParser( 30 description = "Generates a Markdown report for commits on master since the 'latest' tag.", 31 formatter_class = argparse.ArgumentDefaultsHelpFormatter 32) 33flag_parser.add_argument( 34 "--rev-range", 35 help = "A git revision range; see https://git-scm.com/docs/gitrevisions. Should be the two-dot range between the previous release and the current tip.", 36 required = True 37) 38flag_parser.add_argument( 39 "--repo-root", 40 help = "Path to the repository to check", 41 default = os.path.join(os.path.dirname(__file__), "..", "..") 42) 43flag_parser.add_argument( 44 "--jira-hostname", 45 help = "Hostname of the Jira instance", 46 default = "unicode-org.atlassian.net" 47) 48flag_parser.add_argument( 49 "--jira-username", 50 help = "Username to use for authenticating to Jira", 51 default = os.environ.get("JIRA_USERNAME", None) 52) 53flag_parser.add_argument( 54 "--jira-password", 55 help = "Password to use for authenticating to Jira. Authentication is necessary to process sensitive tickets. Leave empty to skip authentication. Instead of passing your password on the command line, you can save your password in the JIRA_PASSWORD environment variable. You can also create a file in this directory named \".env\" with the contents \"JIRA_PASSWORD=xxxxx\".", 56 default = os.environ.get("JIRA_PASSWORD", None) 57) 58flag_parser.add_argument( 59 "--jira-query", 60 help = "JQL query load tickets; this should match tickets expected to correspond to the commits being checked. Example: 'project=ICU and fixVersion=63.1'; set fixVersion to the upcoming version.", 61 required = True 62) 63flag_parser.add_argument( 64 "--github-url", 65 help = "Base URL of the GitHub repo", 66 default = "https://github.com/unicode-org/icu" 67) 68 69 70def issue_id_to_url(issue_id, jira_hostname, **kwargs): 71 return "https://%s/browse/%s" % (jira_hostname, issue_id) 72 73 74def pretty_print_commit(commit, github_url, **kwargs): 75 print("- %s `%s`" % (commit.commit.hexsha[:7], commit.commit.summary)) 76 print("\t- Authored by %s <%s>" % (commit.commit.author.name, commit.commit.author.email)) 77 print("\t- Committed at %s" % commit.commit.committed_datetime.isoformat()) 78 print("\t- GitHub Link: %s" % "%s/commit/%s" % (github_url, commit.commit.hexsha)) 79 80 81def pretty_print_issue(issue, **kwargs): 82 print("- %s: `%s`" % (issue.issue_id, issue.issue.fields.summary)) 83 if issue.issue.fields.assignee: 84 print("\t- Assigned to %s" % issue.issue.fields.assignee.displayName) 85 else: 86 print("\t- No assignee!") 87 print("\t- Jira Link: %s" % issue_id_to_url(issue.issue_id, **kwargs)) 88 89 90def get_commits(repo_root, rev_range, **kwargs): 91 """ 92 Yields an ICUCommit for each commit in the user-specified rev-range. 93 """ 94 repo = Repo(repo_root) 95 for commit in repo.iter_commits(rev_range): 96 match = re.search(r"^(\w+-\d+) ", commit.message) 97 if match: 98 yield ICUCommit(match.group(1), commit) 99 else: 100 yield ICUCommit(None, commit) 101 102 103def get_jira_instance(jira_hostname, jira_username, jira_password, **kwargs): 104 jira_url = "https://%s" % jira_hostname 105 if jira_username and jira_password: 106 jira = JIRA(jira_url, basic_auth=(jira_username, jira_password)) 107 else: 108 jira = JIRA(jira_url) 109 return (jira_url, jira) 110 111 112def make_icu_issue(jira_issue): 113 # Resolution ID 10004 is "Fixed" 114 # Resolution ID 10015 is "Fixed by Other Ticket" 115 if not jira_issue.fields.resolution: 116 commit_wanted = CommitWanted["OPTIONAL"] 117 elif jira_issue.fields.resolution.id == "10015": 118 commit_wanted = CommitWanted["FORBIDDEN"] 119 elif jira_issue.fields.resolution.id != "10004": 120 commit_wanted = CommitWanted["ERROR"] 121 # Issue Type ID 10010 is User Guide 122 # Issue Type ID 10003 is Task 123 elif jira_issue.fields.issuetype.id == "10010" or jira_issue.fields.issuetype.id == "10003": 124 commit_wanted = CommitWanted["OPTIONAL"] 125 else: 126 commit_wanted = CommitWanted["REQUIRED"] 127 # Status ID 10002 is "Done" 128 return ICUIssue(jira_issue.key, jira_issue.fields.status.id == "10002", commit_wanted, jira_issue) 129 130 131def get_jira_issues(jira_query, **kwargs): 132 """ 133 Yields an ICUIssue for each issue in the user-specified query. 134 """ 135 jira_url, jira = get_jira_instance(**kwargs) 136 # Jira limits us to query the API using a limited batch size. 137 start = 0 138 batch_size = 50 139 while True: 140 issues = jira.search_issues(jira_query, startAt=start, maxResults=batch_size) 141 print("Loaded issues %d-%d" % (start, start + len(issues)), file=sys.stderr) 142 for jira_issue in issues: 143 yield make_icu_issue(jira_issue) 144 if len(issues) < batch_size: 145 break 146 start += batch_size 147 148 149def get_single_jira_issue(issue_id, **kwargs): 150 """ 151 Returns a single ICUIssue for the given issue ID. 152 """ 153 jira_url, jira = get_jira_instance(**kwargs) 154 jira_issue = jira.issue(issue_id) 155 print("Loaded single issue %s" % issue_id, file=sys.stderr) 156 if jira_issue: 157 return make_icu_issue(jira_issue) 158 else: 159 return None 160 161 162def main(): 163 args = flag_parser.parse_args() 164 print("TIP: Have you pulled the latest master? This script only looks at local commits.", file=sys.stderr) 165 if not args.jira_username or not args.jira_password: 166 print("WARNING: Jira credentials not supplied. Sensitive tickets will not be found.", file=sys.stderr) 167 authenticated = False 168 else: 169 authenticated = True 170 171 commits = list(get_commits(**vars(args))) 172 issues = list(get_jira_issues(**vars(args))) 173 174 commit_issue_ids = set(commit.issue_id for commit in commits if commit.issue_id is not None) 175 grouped_commits = [ 176 (issue_id, [commit for commit in commits if commit.issue_id == issue_id]) 177 for issue_id in sorted(commit_issue_ids) 178 ] 179 jira_issue_map = {issue.issue_id: issue for issue in issues} 180 jira_issue_ids = set(issue.issue_id for issue in issues) 181 closed_jira_issue_ids = set(issue.issue_id for issue in issues if issue.is_closed) 182 183 total_problems = 0 184 print("<!---") 185 print("Copyright (C) 2018 and later: Unicode, Inc. and others.") 186 print("License & terms of use: http://www.unicode.org/copyright.html") 187 print("-->") 188 print() 189 print("Commit Report") 190 print("=============") 191 print() 192 print("Environment:") 193 print("- Latest Commit: %s" % commits[0].commit.hexsha) 194 print("- Jira Query: %s" % args.jira_query) 195 print("- Rev Range: %s" % args.rev_range) 196 print("- Authenticated: %s" % "Yes" if authenticated else "No (sensitive tickets not shown)") 197 print() 198 print("## Problem Categories") 199 print("### Closed Issues with No Commit") 200 print("Tip: Tickets with type 'Task' or 'User Guide' or resolution 'Fixed by Other Ticket' are ignored.") 201 print() 202 found = False 203 for issue in issues: 204 if not issue.is_closed: 205 continue 206 if issue.issue_id in commit_issue_ids: 207 continue 208 if issue.commit_wanted == CommitWanted["OPTIONAL"] or issue.commit_wanted == CommitWanted["FORBIDDEN"]: 209 continue 210 found = True 211 total_problems += 1 212 pretty_print_issue(issue, **vars(args)) 213 print() 214 if not found: 215 print("*Success: No problems in this category!*") 216 217 print("### Closed Issues with Illegal Resolution or Commit") 218 print("Tip: Fixed tickets should have resolution 'Fixed by Other Ticket' or 'Fixed'.") 219 print("Duplicate tickets should have their fixVersion tag removed.") 220 print("Tickets with resolution 'Fixed by Other Ticket' are not allowed to have commits.") 221 print() 222 found = False 223 for issue in issues: 224 if not issue.is_closed: 225 continue 226 if issue.commit_wanted == CommitWanted["OPTIONAL"]: 227 continue 228 if issue.issue_id in commit_issue_ids and issue.commit_wanted == CommitWanted["REQUIRED"]: 229 continue 230 if issue.issue_id not in commit_issue_ids and issue.commit_wanted == CommitWanted["FORBIDDEN"]: 231 continue 232 found = True 233 total_problems += 1 234 pretty_print_issue(issue, **vars(args)) 235 print() 236 if not found: 237 print("*Success: No problems in this category!*") 238 239 print() 240 print("### Commits without Jira Issue Tag") 241 print("Tip: If you see your name here, make sure to label your commits correctly in the future.") 242 print() 243 found = False 244 for commit in commits: 245 if commit.issue_id is not None: 246 continue 247 found = True 248 total_problems += 1 249 pretty_print_commit(commit, **vars(args)) 250 print() 251 if not found: 252 print("*Success: No problems in this category!*") 253 254 print() 255 print("### Commits with Jira Issue Not Found") 256 print("Tip: Check that these tickets have the correct fixVersion tag.") 257 print() 258 found = False 259 for issue_id, commits in grouped_commits: 260 if issue_id in jira_issue_ids: 261 continue 262 found = True 263 total_problems += 1 264 print("#### Issue %s" % issue_id) 265 print() 266 jira_issue = get_single_jira_issue(issue_id, **vars(args)) 267 if jira_issue: 268 pretty_print_issue(jira_issue, **vars(args)) 269 else: 270 print("*Jira issue does not seem to exist*") 271 print() 272 print("##### Commits with Issue %s" % issue_id) 273 print() 274 for commit in commits: 275 pretty_print_commit(commit, **vars(args)) 276 print() 277 if not found: 278 print("*Success: No problems in this category!*") 279 280 print() 281 print("### Commits with Open Jira Issue") 282 print("Tip: Consider closing the ticket if it is fixed.") 283 print() 284 found = False 285 for issue_id, commits in grouped_commits: 286 if issue_id in closed_jira_issue_ids: 287 continue 288 print("#### Issue %s" % issue_id) 289 print() 290 if issue_id in jira_issue_map: 291 jira_issue = jira_issue_map[issue_id] 292 else: 293 jira_issue = get_single_jira_issue(issue_id, **vars(args)) 294 if jira_issue: 295 pretty_print_issue(jira_issue, **vars(args)) 296 else: 297 print("*Jira issue does not seem to exist*") 298 print() 299 print("##### Commits with Issue %s" % issue_id) 300 print() 301 found = True 302 total_problems += 1 303 for commit in commits: 304 pretty_print_commit(commit, **vars(args)) 305 print() 306 if not found: 307 print("*Success: No problems in this category!*") 308 309 print() 310 print("## Total Problems: %s" % total_problems) 311 312 313if __name__ == "__main__": 314 main() 315