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 11import datetime 12 13from enum import Enum 14from collections import namedtuple 15from git import Repo 16from jira import JIRA 17 18# singleCount = 0 19 20ICUCommit = namedtuple("ICUCommit", ["issue_id", "commit"]) 21 22class CommitWanted(Enum): 23 REQUIRED = 1 24 OPTIONAL = 2 25 FORBIDDEN = 3 26 ERROR = 4 27 28ICUIssue = namedtuple("ICUIssue", ["issue_id", "is_closed", "commit_wanted", "issue"]) 29 30# JIRA constants. 31 32# TODO: clearly these should move into a config file of some sort. 33# NB: you can fetch the resolution IDs by authenticating to JIRA and then viewing 34# the URL given. 35 36# constants for jira_issue.fields.resolution.id 37# <https://unicode-org.atlassian.net/rest/api/2/resolution> 38R_NEEDS_MOREINFO = "10003" 39R_FIXED = "10004" 40R_DUPLICATE = "10006" 41R_OUTOFSCOPE = "10008" 42R_ASDESIGNED = "10009" 43R_WONTFIX = "10010" # deprecated 44R_INVALID = "10012" 45R_FIXED_BY_OTHER_TICKET = "10015" 46R_NOTREPRO = "10024" 47R_FIXED_NON_REPO = "10025" 48R_FIX_SURVEY_TOOL = "10022" 49R_OBSOLETE = "10023" 50 51# constants for jira_issue.fields.issuetype.id 52# <https://unicode-org.atlassian.net/rest/api/2/issuetype> 53I_ICU_USERGUIDE = "10010" 54I_TASK = "10003" 55 56# constants for jira_issue.fields.status.id 57# <https://unicode-org.atlassian.net/rest/api/2/status> 58S_REVIEWING = "10001" 59S_DONE = "10002" 60S_REVIEW_FEEDBACK = "10003" 61 62def jira_issue_under_review(jira_issue): 63 """ 64 Yields True if ticket is considered "under review" 65 """ 66 # TODO: should be data driven from a config file. 67 if jira_issue.issue.fields.status.id in [S_REVIEWING, S_REVIEW_FEEDBACK]: 68 return True 69 else: 70 return False 71 72def make_commit_wanted(jira_issue): 73 """Yields a CommitWanted enum with the policy decision for this particular issue""" 74 # TODO: should be data driven from a config file. 75 if not jira_issue.fields.resolution: 76 commit_wanted = CommitWanted["OPTIONAL"] 77 elif jira_issue.fields.resolution.id in [ R_DUPLICATE, R_ASDESIGNED, R_OUTOFSCOPE, R_NOTREPRO, R_INVALID, R_NEEDS_MOREINFO, R_OBSOLETE ]: 78 commit_wanted = CommitWanted["FORBIDDEN"] 79 elif jira_issue.fields.resolution.id in [ R_FIXED_NON_REPO, R_FIX_SURVEY_TOOL, R_FIXED_BY_OTHER_TICKET ]: 80 commit_wanted = CommitWanted["FORBIDDEN"] 81 elif jira_issue.fields.issuetype.id in [ I_ICU_USERGUIDE, I_TASK ]: 82 commit_wanted = CommitWanted["OPTIONAL"] 83 elif jira_issue.fields.resolution.id in [ R_FIXED ]: 84 commit_wanted = CommitWanted["REQUIRED"] 85 elif jira_issue.fields.resolution.id == R_FIXED_BY_OTHER_TICKET: 86 commit_wanted = CommitWanted["FORBIDDEN"] 87 elif jira_issue.fields.resolution.id != R_FIXED: 88 commit_wanted = CommitWanted["ERROR"] 89 else: 90 commit_wanted = CommitWanted["REQUIRED"] 91 return commit_wanted 92 93 94flag_parser = argparse.ArgumentParser( 95 description = "Generates a Markdown report for commits on main since the 'latest' tag.", 96 formatter_class = argparse.ArgumentDefaultsHelpFormatter 97) 98flag_parser.add_argument( 99 "--rev-range", 100 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.", 101 required = True 102) 103flag_parser.add_argument( 104 "--repo-root", 105 help = "Path to the repository to check", 106 default = os.path.join(os.path.dirname(__file__), "..", "..") 107) 108flag_parser.add_argument( 109 "--jira-hostname", 110 help = "Hostname of the Jira instance", 111 default = "unicode-org.atlassian.net" 112) 113flag_parser.add_argument( 114 "--jira-username", 115 help = "Username to use for authenticating to Jira", 116 default = os.environ.get("JIRA_USERNAME", None) 117) 118flag_parser.add_argument( 119 "--jira-password", 120 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\".", 121 default = os.environ.get("JIRA_PASSWORD", None) 122) 123flag_parser.add_argument( 124 "--jira-query", 125 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.", 126 required = True 127) 128flag_parser.add_argument( 129 "--github-url", 130 help = "Base URL of the GitHub repo", 131 default = "https://github.com/unicode-org/icu" 132) 133flag_parser.add_argument( 134 "--nocopyright", 135 help = "Omit ICU copyright", 136 action = "store_true" 137) 138 139 140def issue_id_to_url(issue_id, jira_hostname, **kwargs): 141 return "https://%s/browse/%s" % (jira_hostname, issue_id) 142 143 144def pretty_print_commit(commit, github_url, **kwargs): 145 print("- %s `%s`" % (commit.commit.hexsha[:7], commit.commit.summary)) 146 print("\t- Authored by %s <%s>" % (commit.commit.author.name, commit.commit.author.email)) 147 print("\t- Committed at %s" % commit.commit.committed_datetime.isoformat()) 148 print("\t- GitHub Link: %s" % "%s/commit/%s" % (github_url, commit.commit.hexsha)) 149 150 151def pretty_print_issue(issue, type=None, **kwargs): 152 print("- %s: `%s`" % (issue.issue_id, issue.issue.fields.summary)) 153 if type: 154 print("\t- _%s_" % type) 155 if issue.issue.fields.assignee: 156 print("\t- Assigned to %s" % issue.issue.fields.assignee.displayName) 157 else: 158 print("\t- No assignee!") 159 # If actually under review, print reviewer 160 if jira_issue_under_review(issue) and issue.issue.fields.customfield_10031: 161 print("\t- Reviewer: %s" % issue.issue.fields.customfield_10031.displayName) 162 print("\t- Jira Link: %s" % issue_id_to_url(issue.issue_id, **kwargs)) 163 print("\t- Status: %s" % issue.issue.fields.status.name) 164 if(issue.issue.fields.resolution): 165 print("\t- Resolution: " + issue.issue.fields.resolution.name) 166 if(issue.issue.fields.fixVersions): 167 for version in issue.issue.fields.fixVersions: 168 print("\t- Fix Version: " + version.name) 169 else: 170 print("\t- Fix Version: _none_") 171 if issue.issue.fields.components and len(issue.issue.fields.components) > 0: 172 print("\t- Component(s): " + (' '.join(sorted([str(component.name) for component in issue.issue.fields.components])))) 173 174def get_commits(repo_root, rev_range, **kwargs): 175 """ 176 Yields an ICUCommit for each commit in the user-specified rev-range. 177 """ 178 repo = Repo(repo_root) 179 for commit in repo.iter_commits(rev_range): 180 match = re.search(r"^(\w+-\d+) ", commit.message) 181 if match: 182 issue_id = match.group(1) 183 # print("@@@ %s = %s / %s" % (issue_id, commit, commit.summary), file=sys.stderr) 184 yield ICUCommit(issue_id, commit) 185 else: 186 yield ICUCommit(None, commit) 187 188def get_cherrypicked_commits(repo_root, rev_range, **kwargs): 189 """ 190 Yields a set of commit SHAs (strings) that should be EXCLUDED from 191 "missing jira" consideration, because they have already been cherry-picked onto the maint branch. 192 """ 193 repo = Repo(repo_root) 194 [a, b] = splitRevRange(rev_range) 195 branchCut = get_branchcut_sha(repo_root, rev_range) 196 print ("## git cherry %s %s %s (branch cut)" % (a, b, branchCut), file=sys.stderr) 197 cherries = repo.git.cherry(a, b, branchCut) 198 lns = cherries.split('\n') 199 excludeThese = set() 200 for ln in lns: 201 [symbol, sha] = ln.split(' ') 202 if(symbol == '-'): 203 # print("Exclude: %s" % sha, file=sys.stderr) 204 excludeThese.add(sha) 205 print("## Collected %d commit(s) to exclude" % len(excludeThese)) 206 return excludeThese 207 208def splitRevRange(rev_range): 209 """ 210 Return the start and end of the revrange 211 """ 212 return rev_range.split('..') 213 214def get_branchcut_sha(repo_root, rev_range): 215 """ 216 Return the sha of the 'branch cut', that is, the merge-base. 217 Returns a git commit 218 """ 219 repo = Repo(repo_root) 220 [a, b] = splitRevRange(rev_range) 221 return repo.merge_base(a, b)[0] 222 223def get_jira_instance(jira_hostname, jira_username, jira_password, **kwargs): 224 jira_url = "https://%s" % jira_hostname 225 if jira_username and jira_password: 226 jira = JIRA(jira_url, basic_auth=(jira_username, jira_password)) 227 else: 228 jira = JIRA(jira_url) 229 return (jira_url, jira) 230 231def make_icu_issue(jira_issue): 232 """Yields an ICUIssue for the individual jira object""" 233 commit_wanted = make_commit_wanted(jira_issue) 234 return ICUIssue(jira_issue.key, jira_issue.fields.status.id == S_DONE, commit_wanted, jira_issue) 235 236 237def get_jira_issues(jira_query, **kwargs): 238 """ 239 Yields an ICUIssue for each issue in the user-specified query. 240 """ 241 jira_url, jira = get_jira_instance(**kwargs) 242 # Jira limits us to query the API using a limited batch size. 243 start = 0 244 batch_size = 100 # https://jira.atlassian.com/browse/JRACLOUD-67570 245 while True: 246 issues = jira.search_issues(jira_query, startAt=start, maxResults=batch_size) 247 if len(issues) > 0: 248 print("Loaded issues %d-%d" % (start + 1, start + len(issues)), file=sys.stderr) 249 else: 250 print(":warning: No issues matched the query.") # leave this as a warning 251 for jira_issue in issues: 252 yield make_icu_issue(jira_issue) 253 if len(issues) < batch_size: 254 break 255 start += batch_size 256 257jira_issue_map = dict() # loaded in main() 258 259def get_single_jira_issue(issue_id, **kwargs): 260 """ 261 Returns a single ICUIssue for the given issue ID. 262 This can always be used (in- or out- of query issues), because it 263 uses the jira_issue_map as the backing store. 264 """ 265 if issue_id in jira_issue_map: 266 # print("Cache hit: issue %s " % (issue_id), file=sys.stderr) 267 return jira_issue_map[issue_id] 268 jira_url, jira = get_jira_instance(**kwargs) 269 jira_issue = jira.issue(issue_id) 270 # singleCount = singleCount + 1 271 if jira_issue: 272 icu_issue = make_icu_issue(jira_issue) 273 else: 274 icu_issue = None 275 jira_issue_map[issue_id] = icu_issue 276 print("Loaded single issue %s (%d in cache) " % (issue_id, len(jira_issue_map)), file=sys.stderr) 277 return icu_issue 278 279def toplink(): 280 print("[Top](#table-of-contents)") 281 print() 282 283def sectionToFragment(section): 284 return re.sub(r' ', '-', section.lower()) 285 286# def aname(section): 287# """convert section name to am anchor""" 288# return "<a name=\"%s\"></a>" % sectionToFragment(section) 289 290def print_sectionheader(section): 291 """Print a section (###) header, including anchor""" 292 print("### %s" % (section)) 293 #print("### %s%s" % (aname(section), section)) 294 295def main(): 296 args = flag_parser.parse_args() 297 print("TIP: Have you pulled the latest main? This script only looks at local commits.", file=sys.stderr) 298 if not args.jira_username or not args.jira_password: 299 print("WARNING: Jira credentials not supplied. Sensitive tickets will not be found.", file=sys.stderr) 300 authenticated = False 301 else: 302 authenticated = True 303 304 # exclude these, already merged to old maint 305 excludeAlreadyMergedToOldMaint = get_cherrypicked_commits(**vars(args)) 306 307 commits = list(get_commits(**vars(args))) 308 issues = list(get_jira_issues(**vars(args))) 309 310 # commit_issue_ids is all commits in the git query. Excluding cherry exclusions. 311 commit_issue_ids = set(commit.issue_id for commit in commits if commit.issue_id is not None and commit.commit.hexsha not in excludeAlreadyMergedToOldMaint) 312 # which issues have commits that were excluded 313 excluded_commit_issue_ids = set(commit.issue_id for commit in commits if commit.issue_id is not None and commit.commit.hexsha in excludeAlreadyMergedToOldMaint) 314 315 # grouped_commits is all commits and issue_ids in the git query, regardless of issue status 316 # but NOT including cherry exclusions 317 grouped_commits = [ 318 (issue_id, [commit for commit in commits if commit.issue_id == issue_id and commit.commit.hexsha not in excludeAlreadyMergedToOldMaint]) 319 for issue_id in sorted(commit_issue_ids) 320 ] 321 # add all queried issues to the cache 322 for issue in issues: 323 jira_issue_map[issue.issue_id] = issue 324 # only the issue ids in-query 325 jira_issue_ids = set(issue.issue_id for issue in issues) 326 # only the closed issue ids in-query 327 closed_jira_issue_ids = set(issue.issue_id for issue in issues if issue.is_closed) 328 329 # keep track of issues that we already said have no commit. 330 no_commit_ids = set() 331 332 # constants for the section names. 333 CLOSED_NO_COMMIT = "Closed Issues with No Commit" 334 CLOSED_ILLEGAL_RESOLUTION = "Closed Issues with Illegal Resolution or Commit" 335 COMMIT_NO_JIRA = "Commits without Jira Issue Tag" 336 COMMIT_OPEN_JIRA = "Commits with Open Jira Issue" 337 COMMIT_JIRA_NOT_IN_QUERY = "Commits with Jira Issue Not Found" 338 ISSUE_UNDER_REVIEW = "Issue is under Review" 339 340 total_problems = 0 341 if not args.nocopyright: 342 print("<!--") 343 print("Copyright (C) 2021 and later: Unicode, Inc. and others.") 344 print("License & terms of use: http://www.unicode.org/copyright.html") 345 print("-->") 346 347 print("Commit Report") 348 print("=============") 349 print() 350 print("Environment:") 351 print("- Now: %s" % datetime.datetime.now().isoformat()) 352 print("- Latest Commit: %s/commit/%s" % (args.github_url, commits[0].commit.hexsha)) 353 print("- Jira Query: `%s`" % args.jira_query) 354 print("- Rev Range: `%s`" % args.rev_range) 355 print("- Authenticated: %s" % ("`Yes`" if authenticated else "`No` (sensitive tickets not shown)")) 356 print() 357 print("## Table Of Contents") 358 for section in [CLOSED_NO_COMMIT, CLOSED_ILLEGAL_RESOLUTION, COMMIT_NO_JIRA, COMMIT_JIRA_NOT_IN_QUERY, COMMIT_OPEN_JIRA, ISSUE_UNDER_REVIEW]: 359 print("- [%s](#%s)" % (section, sectionToFragment(section))) 360 print() 361 print("## Problem Categories") 362 print_sectionheader(CLOSED_NO_COMMIT) 363 toplink() 364 print("Tip: Tickets with type 'Task' or 'User Guide' or resolution 'Fixed by Other Ticket' are ignored.") 365 print() 366 found = False 367 for issue in issues: 368 if not issue.is_closed: 369 continue 370 if issue.issue_id in commit_issue_ids: 371 continue 372 if issue.commit_wanted == CommitWanted["OPTIONAL"] or issue.commit_wanted == CommitWanted["FORBIDDEN"]: 373 continue 374 found = True 375 total_problems += 1 376 no_commit_ids.add(issue.issue_id) 377 pretty_print_issue(issue, type=CLOSED_NO_COMMIT, **vars(args)) 378 if issue.issue_id in excluded_commit_issue_ids: 379 print("\t - **Note: Has cherry-picked commits. Fix Version may be wrong.**") 380 print() 381 if not found: 382 print("*Success: No problems in this category!*") 383 384 print_sectionheader(CLOSED_ILLEGAL_RESOLUTION) 385 toplink() 386 print("Tip: Fixed tickets should have resolution 'Fixed by Other Ticket' or 'Fixed'.") 387 print("Duplicate tickets should have their fixVersion tag removed.") 388 print("Tickets with resolution 'Fixed by Other Ticket' are not allowed to have commits.") 389 print() 390 found = False 391 for issue in issues: 392 if not issue.is_closed: 393 continue 394 if issue.commit_wanted == CommitWanted["OPTIONAL"]: 395 continue 396 if issue.issue_id in commit_issue_ids and issue.commit_wanted == CommitWanted["REQUIRED"]: 397 continue 398 if issue.issue_id not in commit_issue_ids and issue.commit_wanted == CommitWanted["FORBIDDEN"]: 399 continue 400 if issue.issue_id in no_commit_ids: 401 continue # we already complained about it above. don't double count. 402 found = True 403 total_problems += 1 404 pretty_print_issue(issue, type=CLOSED_ILLEGAL_RESOLUTION, **vars(args)) 405 if issue.issue_id not in commit_issue_ids and issue.commit_wanted == CommitWanted["REQUIRED"]: 406 print("\t- No commits, and they are REQUIRED.") 407 if issue.issue_id in commit_issue_ids and issue.commit_wanted == CommitWanted["FORBIDDEN"]: 408 print("\t- Has commits, and they are FORBIDDEN.") 409 print() 410 if not found: 411 print("*Success: No problems in this category!*") 412 413 # TODO: This section should usually be empty due to the PR checker. 414 # Pre-calculate the count and omit it. 415 print() 416 print_sectionheader(COMMIT_NO_JIRA) 417 toplink() 418 print("Tip: If you see your name here, make sure to label your commits correctly in the future.") 419 print() 420 found = False 421 for commit in commits: 422 if commit.issue_id is not None: 423 continue 424 found = True 425 total_problems += 1 426 pretty_print_commit(commit, type=COMMIT_NO_JIRA, **vars(args)) 427 print() 428 if not found: 429 print("*Success: No problems in this category!*") 430 431 print() 432 print_sectionheader(COMMIT_JIRA_NOT_IN_QUERY) 433 toplink() 434 print("Tip: Check that these tickets have the correct fixVersion tag.") 435 print() 436 found = False 437 for issue_id, commits in grouped_commits: 438 if issue_id in jira_issue_ids: 439 continue 440 found = True 441 total_problems += 1 442 print("#### Issue %s" % issue_id) 443 print() 444 print("_issue was not found in `%s`_" % args.jira_query) # TODO: link to query? 445 jira_issue = get_single_jira_issue(issue_id, **vars(args)) 446 if jira_issue: 447 pretty_print_issue(jira_issue, **vars(args)) 448 else: 449 print("*Jira issue does not seem to exist*") 450 print() 451 print("##### Commits with Issue %s" % issue_id) 452 print() 453 for commit in commits: 454 if(commit.commit.hexsha in excludeAlreadyMergedToOldMaint): 455 print("@@@ ALREADY MERGED") 456 pretty_print_commit(commit, **vars(args)) 457 print() 458 if not found: 459 print("*Success: No problems in this category!*") 460 461 print() 462 463 # list of issues that are in review 464 issues_in_review = set() 465 466 print_sectionheader(COMMIT_OPEN_JIRA) 467 toplink() 468 print("Tip: Consider closing the ticket if it is fixed.") 469 print() 470 found = False 471 componentToTicket = {} 472 def addToComponent(component, issue_id): 473 if component not in componentToTicket: 474 componentToTicket[component] = set() 475 componentToTicket[component].add(issue_id) 476 # first, scan ahead for the components 477 for issue_id, commits in grouped_commits: 478 if issue_id in closed_jira_issue_ids: 479 continue 480 jira_issue = get_single_jira_issue(issue_id, **vars(args)) 481 if jira_issue and jira_issue.is_closed: 482 # JIRA ticket was not in query, but was actually closed. 483 continue 484 if jira_issue_under_review(jira_issue): 485 print("skipping for now- %s is under review" % issue_id, file=sys.stderr) 486 issues_in_review.add(issue_id) 487 continue 488 # OK. Now, split it out by component 489 if jira_issue.issue.fields.components and len(jira_issue.issue.fields.components) > 0: 490 for component in jira_issue.issue.fields.components: 491 addToComponent(component.name, issue_id) 492 else: 493 addToComponent("(no component)", issue_id) 494 495 print("#### Open Issues by Component") 496 print() 497 for component in sorted(componentToTicket.keys()): 498 print(" - **%s**: %s" % (component, ' '.join("[%s](#issue-%s)" % (issue_id, sectionToFragment(issue_id)) for issue_id in componentToTicket[component]))) 499 500 print() 501 print() 502 503 # now, actually show the ticket list. 504 for issue_id, commits in grouped_commits: 505 if issue_id in closed_jira_issue_ids: 506 continue 507 jira_issue = get_single_jira_issue(issue_id, **vars(args)) 508 if jira_issue and jira_issue.is_closed: 509 # JIRA ticket was not in query, but was actually closed. 510 continue 511 if jira_issue_under_review(jira_issue): 512 # We already added it to the review list above. 513 continue 514 print("#### Issue %s" % issue_id) 515 print() 516 print("_Jira issue is open_") 517 if jira_issue: 518 pretty_print_issue(jira_issue, **vars(args)) 519 else: 520 print("*Jira issue does not seem to exist*") 521 print() 522 print("##### Commits with Issue %s" % issue_id) 523 print() 524 found = True 525 total_problems += 1 526 for commit in commits: 527 # print("@@@@ %s = %s / %s" % (issue_id, commit, commit.commit.summary), file=sys.stderr) 528 pretty_print_commit(commit, **vars(args)) 529 print() 530 if not found: 531 print("*Success: No problems in this category!*") 532 533 print_sectionheader(ISSUE_UNDER_REVIEW) 534 print() 535 toplink() 536 print("These issues are otherwise accounted for above, but are in review.") 537 for issue_id in sorted(issues_in_review): 538 jira_issue = get_single_jira_issue(issue_id, **vars(args)) 539 pretty_print_issue(jira_issue, type=ISSUE_UNDER_REVIEW, **vars(args)) 540 541 print() 542 print("## Total Problems: %s" % total_problems) 543 print("## Issues under review: %s" % len(issues_in_review)) # not counted as a problem. 544 545if __name__ == "__main__": 546 main() 547