12e5b6d6dSopenharmony_ci#!/usr/bin/env python3
22e5b6d6dSopenharmony_ci# Copyright (C) 2018 and later: Unicode, Inc. and others.
32e5b6d6dSopenharmony_ci# License & terms of use: http://www.unicode.org/copyright.html
42e5b6d6dSopenharmony_ci# Author: shane@unicode.org
52e5b6d6dSopenharmony_ci
62e5b6d6dSopenharmony_ciimport argparse
72e5b6d6dSopenharmony_ciimport itertools
82e5b6d6dSopenharmony_ciimport os
92e5b6d6dSopenharmony_ciimport re
102e5b6d6dSopenharmony_ciimport sys
112e5b6d6dSopenharmony_ciimport datetime
122e5b6d6dSopenharmony_ci
132e5b6d6dSopenharmony_cifrom enum import Enum
142e5b6d6dSopenharmony_cifrom collections import namedtuple
152e5b6d6dSopenharmony_cifrom git import Repo
162e5b6d6dSopenharmony_cifrom jira import JIRA
172e5b6d6dSopenharmony_ci
182e5b6d6dSopenharmony_ci# singleCount = 0
192e5b6d6dSopenharmony_ci
202e5b6d6dSopenharmony_ciICUCommit = namedtuple("ICUCommit", ["issue_id", "commit"])
212e5b6d6dSopenharmony_ci
222e5b6d6dSopenharmony_ciclass CommitWanted(Enum):
232e5b6d6dSopenharmony_ci    REQUIRED = 1
242e5b6d6dSopenharmony_ci    OPTIONAL = 2
252e5b6d6dSopenharmony_ci    FORBIDDEN = 3
262e5b6d6dSopenharmony_ci    ERROR = 4
272e5b6d6dSopenharmony_ci
282e5b6d6dSopenharmony_ciICUIssue = namedtuple("ICUIssue", ["issue_id", "is_closed", "commit_wanted", "issue"])
292e5b6d6dSopenharmony_ci
302e5b6d6dSopenharmony_ci# JIRA constants.
312e5b6d6dSopenharmony_ci
322e5b6d6dSopenharmony_ci# TODO: clearly these should move into a config file of some sort.
332e5b6d6dSopenharmony_ci# NB: you can fetch the resolution IDs by authenticating to JIRA and then viewing
342e5b6d6dSopenharmony_ci# the URL given.
352e5b6d6dSopenharmony_ci
362e5b6d6dSopenharmony_ci# constants for jira_issue.fields.resolution.id
372e5b6d6dSopenharmony_ci# <https://unicode-org.atlassian.net/rest/api/2/resolution>
382e5b6d6dSopenharmony_ciR_NEEDS_MOREINFO        = "10003"
392e5b6d6dSopenharmony_ciR_FIXED                 = "10004"
402e5b6d6dSopenharmony_ciR_DUPLICATE             = "10006"
412e5b6d6dSopenharmony_ciR_OUTOFSCOPE            = "10008"
422e5b6d6dSopenharmony_ciR_ASDESIGNED            = "10009"
432e5b6d6dSopenharmony_ciR_WONTFIX               = "10010"  # deprecated
442e5b6d6dSopenharmony_ciR_INVALID               = "10012"
452e5b6d6dSopenharmony_ciR_FIXED_BY_OTHER_TICKET = "10015"
462e5b6d6dSopenharmony_ciR_NOTREPRO              = "10024"
472e5b6d6dSopenharmony_ciR_FIXED_NON_REPO        = "10025"
482e5b6d6dSopenharmony_ciR_FIX_SURVEY_TOOL       = "10022"
492e5b6d6dSopenharmony_ciR_OBSOLETE              = "10023"
502e5b6d6dSopenharmony_ci
512e5b6d6dSopenharmony_ci# constants for jira_issue.fields.issuetype.id
522e5b6d6dSopenharmony_ci# <https://unicode-org.atlassian.net/rest/api/2/issuetype>
532e5b6d6dSopenharmony_ciI_ICU_USERGUIDE         = "10010"
542e5b6d6dSopenharmony_ciI_TASK                  = "10003"
552e5b6d6dSopenharmony_ci
562e5b6d6dSopenharmony_ci# constants for jira_issue.fields.status.id
572e5b6d6dSopenharmony_ci# <https://unicode-org.atlassian.net/rest/api/2/status>
582e5b6d6dSopenharmony_ciS_REVIEWING             = "10001"
592e5b6d6dSopenharmony_ciS_DONE                  = "10002"
602e5b6d6dSopenharmony_ciS_REVIEW_FEEDBACK       = "10003"
612e5b6d6dSopenharmony_ci
622e5b6d6dSopenharmony_cidef jira_issue_under_review(jira_issue):
632e5b6d6dSopenharmony_ci    """
642e5b6d6dSopenharmony_ci    Yields True if ticket is considered "under review"
652e5b6d6dSopenharmony_ci    """
662e5b6d6dSopenharmony_ci    # TODO: should be data driven from a config file.
672e5b6d6dSopenharmony_ci    if jira_issue.issue.fields.status.id in [S_REVIEWING, S_REVIEW_FEEDBACK]:
682e5b6d6dSopenharmony_ci        return True
692e5b6d6dSopenharmony_ci    else:
702e5b6d6dSopenharmony_ci        return False
712e5b6d6dSopenharmony_ci
722e5b6d6dSopenharmony_cidef make_commit_wanted(jira_issue):
732e5b6d6dSopenharmony_ci    """Yields a CommitWanted enum with the policy decision for this particular issue"""
742e5b6d6dSopenharmony_ci    # TODO: should be data driven from a config file.
752e5b6d6dSopenharmony_ci    if not jira_issue.fields.resolution:
762e5b6d6dSopenharmony_ci        commit_wanted = CommitWanted["OPTIONAL"]
772e5b6d6dSopenharmony_ci    elif jira_issue.fields.resolution.id in [ R_DUPLICATE, R_ASDESIGNED, R_OUTOFSCOPE, R_NOTREPRO, R_INVALID, R_NEEDS_MOREINFO, R_OBSOLETE ]:
782e5b6d6dSopenharmony_ci        commit_wanted = CommitWanted["FORBIDDEN"]
792e5b6d6dSopenharmony_ci    elif jira_issue.fields.resolution.id in [ R_FIXED_NON_REPO, R_FIX_SURVEY_TOOL, R_FIXED_BY_OTHER_TICKET ]:
802e5b6d6dSopenharmony_ci        commit_wanted = CommitWanted["FORBIDDEN"]
812e5b6d6dSopenharmony_ci    elif jira_issue.fields.issuetype.id in [ I_ICU_USERGUIDE, I_TASK ]:
822e5b6d6dSopenharmony_ci        commit_wanted = CommitWanted["OPTIONAL"]
832e5b6d6dSopenharmony_ci    elif jira_issue.fields.resolution.id in [ R_FIXED ]:
842e5b6d6dSopenharmony_ci        commit_wanted = CommitWanted["REQUIRED"]
852e5b6d6dSopenharmony_ci    elif jira_issue.fields.resolution.id == R_FIXED_BY_OTHER_TICKET:
862e5b6d6dSopenharmony_ci        commit_wanted = CommitWanted["FORBIDDEN"]
872e5b6d6dSopenharmony_ci    elif jira_issue.fields.resolution.id != R_FIXED:
882e5b6d6dSopenharmony_ci        commit_wanted = CommitWanted["ERROR"]
892e5b6d6dSopenharmony_ci    else:
902e5b6d6dSopenharmony_ci        commit_wanted = CommitWanted["REQUIRED"]
912e5b6d6dSopenharmony_ci    return commit_wanted
922e5b6d6dSopenharmony_ci
932e5b6d6dSopenharmony_ci
942e5b6d6dSopenharmony_ciflag_parser = argparse.ArgumentParser(
952e5b6d6dSopenharmony_ci    description = "Generates a Markdown report for commits on main since the 'latest' tag.",
962e5b6d6dSopenharmony_ci    formatter_class = argparse.ArgumentDefaultsHelpFormatter
972e5b6d6dSopenharmony_ci)
982e5b6d6dSopenharmony_ciflag_parser.add_argument(
992e5b6d6dSopenharmony_ci    "--rev-range",
1002e5b6d6dSopenharmony_ci    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.",
1012e5b6d6dSopenharmony_ci    required = True
1022e5b6d6dSopenharmony_ci)
1032e5b6d6dSopenharmony_ciflag_parser.add_argument(
1042e5b6d6dSopenharmony_ci    "--repo-root",
1052e5b6d6dSopenharmony_ci    help = "Path to the repository to check",
1062e5b6d6dSopenharmony_ci    default = os.path.join(os.path.dirname(__file__), "..", "..")
1072e5b6d6dSopenharmony_ci)
1082e5b6d6dSopenharmony_ciflag_parser.add_argument(
1092e5b6d6dSopenharmony_ci    "--jira-hostname",
1102e5b6d6dSopenharmony_ci    help = "Hostname of the Jira instance",
1112e5b6d6dSopenharmony_ci    default = "unicode-org.atlassian.net"
1122e5b6d6dSopenharmony_ci)
1132e5b6d6dSopenharmony_ciflag_parser.add_argument(
1142e5b6d6dSopenharmony_ci    "--jira-username",
1152e5b6d6dSopenharmony_ci    help = "Username to use for authenticating to Jira",
1162e5b6d6dSopenharmony_ci    default = os.environ.get("JIRA_USERNAME", None)
1172e5b6d6dSopenharmony_ci)
1182e5b6d6dSopenharmony_ciflag_parser.add_argument(
1192e5b6d6dSopenharmony_ci    "--jira-password",
1202e5b6d6dSopenharmony_ci    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\".",
1212e5b6d6dSopenharmony_ci    default = os.environ.get("JIRA_PASSWORD", None)
1222e5b6d6dSopenharmony_ci)
1232e5b6d6dSopenharmony_ciflag_parser.add_argument(
1242e5b6d6dSopenharmony_ci    "--jira-query",
1252e5b6d6dSopenharmony_ci    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.",
1262e5b6d6dSopenharmony_ci    required = True
1272e5b6d6dSopenharmony_ci)
1282e5b6d6dSopenharmony_ciflag_parser.add_argument(
1292e5b6d6dSopenharmony_ci    "--github-url",
1302e5b6d6dSopenharmony_ci    help = "Base URL of the GitHub repo",
1312e5b6d6dSopenharmony_ci    default = "https://github.com/unicode-org/icu"
1322e5b6d6dSopenharmony_ci)
1332e5b6d6dSopenharmony_ciflag_parser.add_argument(
1342e5b6d6dSopenharmony_ci    "--nocopyright",
1352e5b6d6dSopenharmony_ci    help = "Omit ICU copyright",
1362e5b6d6dSopenharmony_ci    action = "store_true"
1372e5b6d6dSopenharmony_ci)
1382e5b6d6dSopenharmony_ci
1392e5b6d6dSopenharmony_ci
1402e5b6d6dSopenharmony_cidef issue_id_to_url(issue_id, jira_hostname, **kwargs):
1412e5b6d6dSopenharmony_ci    return "https://%s/browse/%s" % (jira_hostname, issue_id)
1422e5b6d6dSopenharmony_ci
1432e5b6d6dSopenharmony_ci
1442e5b6d6dSopenharmony_cidef pretty_print_commit(commit, github_url, **kwargs):
1452e5b6d6dSopenharmony_ci    print("- %s `%s`" % (commit.commit.hexsha[:7], commit.commit.summary))
1462e5b6d6dSopenharmony_ci    print("\t- Authored by %s <%s>" % (commit.commit.author.name, commit.commit.author.email))
1472e5b6d6dSopenharmony_ci    print("\t- Committed at %s" % commit.commit.committed_datetime.isoformat())
1482e5b6d6dSopenharmony_ci    print("\t- GitHub Link: %s" % "%s/commit/%s" % (github_url, commit.commit.hexsha))
1492e5b6d6dSopenharmony_ci
1502e5b6d6dSopenharmony_ci
1512e5b6d6dSopenharmony_cidef pretty_print_issue(issue, type=None, **kwargs):
1522e5b6d6dSopenharmony_ci    print("- %s: `%s`" % (issue.issue_id, issue.issue.fields.summary))
1532e5b6d6dSopenharmony_ci    if type:
1542e5b6d6dSopenharmony_ci        print("\t- _%s_" % type)
1552e5b6d6dSopenharmony_ci    if issue.issue.fields.assignee:
1562e5b6d6dSopenharmony_ci        print("\t- Assigned to %s" % issue.issue.fields.assignee.displayName)
1572e5b6d6dSopenharmony_ci    else:
1582e5b6d6dSopenharmony_ci        print("\t- No assignee!")
1592e5b6d6dSopenharmony_ci    # If actually under review, print reviewer
1602e5b6d6dSopenharmony_ci    if jira_issue_under_review(issue) and issue.issue.fields.customfield_10031:
1612e5b6d6dSopenharmony_ci        print("\t- Reviewer: %s" % issue.issue.fields.customfield_10031.displayName)
1622e5b6d6dSopenharmony_ci    print("\t- Jira Link: %s" % issue_id_to_url(issue.issue_id, **kwargs))
1632e5b6d6dSopenharmony_ci    print("\t- Status: %s" % issue.issue.fields.status.name)
1642e5b6d6dSopenharmony_ci    if(issue.issue.fields.resolution):
1652e5b6d6dSopenharmony_ci        print("\t- Resolution: " + issue.issue.fields.resolution.name)
1662e5b6d6dSopenharmony_ci    if(issue.issue.fields.fixVersions):
1672e5b6d6dSopenharmony_ci        for version in issue.issue.fields.fixVersions:
1682e5b6d6dSopenharmony_ci            print("\t- Fix Version: " + version.name)
1692e5b6d6dSopenharmony_ci    else:
1702e5b6d6dSopenharmony_ci        print("\t- Fix Version: _none_")
1712e5b6d6dSopenharmony_ci    if issue.issue.fields.components and len(issue.issue.fields.components) > 0:
1722e5b6d6dSopenharmony_ci        print("\t- Component(s): " + (' '.join(sorted([str(component.name) for component in issue.issue.fields.components]))))
1732e5b6d6dSopenharmony_ci
1742e5b6d6dSopenharmony_cidef get_commits(repo_root, rev_range, **kwargs):
1752e5b6d6dSopenharmony_ci    """
1762e5b6d6dSopenharmony_ci    Yields an ICUCommit for each commit in the user-specified rev-range.
1772e5b6d6dSopenharmony_ci    """
1782e5b6d6dSopenharmony_ci    repo = Repo(repo_root)
1792e5b6d6dSopenharmony_ci    for commit in repo.iter_commits(rev_range):
1802e5b6d6dSopenharmony_ci        match = re.search(r"^(\w+-\d+) ", commit.message)
1812e5b6d6dSopenharmony_ci        if match:
1822e5b6d6dSopenharmony_ci            issue_id = match.group(1)
1832e5b6d6dSopenharmony_ci            # print("@@@ %s = %s / %s" % (issue_id, commit, commit.summary), file=sys.stderr)
1842e5b6d6dSopenharmony_ci            yield ICUCommit(issue_id, commit)
1852e5b6d6dSopenharmony_ci        else:
1862e5b6d6dSopenharmony_ci            yield ICUCommit(None, commit)
1872e5b6d6dSopenharmony_ci
1882e5b6d6dSopenharmony_cidef get_cherrypicked_commits(repo_root, rev_range, **kwargs):
1892e5b6d6dSopenharmony_ci    """
1902e5b6d6dSopenharmony_ci    Yields a set of commit SHAs (strings) that should be EXCLUDED from
1912e5b6d6dSopenharmony_ci    "missing jira" consideration, because they have already been cherry-picked onto the maint branch.
1922e5b6d6dSopenharmony_ci    """
1932e5b6d6dSopenharmony_ci    repo = Repo(repo_root)
1942e5b6d6dSopenharmony_ci    [a, b] = splitRevRange(rev_range)
1952e5b6d6dSopenharmony_ci    branchCut = get_branchcut_sha(repo_root, rev_range)
1962e5b6d6dSopenharmony_ci    print ("## git cherry %s %s %s (branch cut)" % (a, b, branchCut), file=sys.stderr)
1972e5b6d6dSopenharmony_ci    cherries = repo.git.cherry(a, b, branchCut)
1982e5b6d6dSopenharmony_ci    lns = cherries.split('\n')
1992e5b6d6dSopenharmony_ci    excludeThese = set()
2002e5b6d6dSopenharmony_ci    for ln in lns:
2012e5b6d6dSopenharmony_ci        [symbol, sha] = ln.split(' ')
2022e5b6d6dSopenharmony_ci        if(symbol == '-'):
2032e5b6d6dSopenharmony_ci            # print("Exclude: %s" % sha, file=sys.stderr)
2042e5b6d6dSopenharmony_ci            excludeThese.add(sha)
2052e5b6d6dSopenharmony_ci    print("## Collected %d commit(s) to exclude" % len(excludeThese))
2062e5b6d6dSopenharmony_ci    return excludeThese
2072e5b6d6dSopenharmony_ci
2082e5b6d6dSopenharmony_cidef splitRevRange(rev_range):
2092e5b6d6dSopenharmony_ci    """
2102e5b6d6dSopenharmony_ci    Return the start and end of the revrange
2112e5b6d6dSopenharmony_ci    """
2122e5b6d6dSopenharmony_ci    return rev_range.split('..')
2132e5b6d6dSopenharmony_ci
2142e5b6d6dSopenharmony_cidef get_branchcut_sha(repo_root, rev_range):
2152e5b6d6dSopenharmony_ci    """
2162e5b6d6dSopenharmony_ci    Return the sha of the 'branch cut', that is, the merge-base.
2172e5b6d6dSopenharmony_ci    Returns a git commit
2182e5b6d6dSopenharmony_ci    """
2192e5b6d6dSopenharmony_ci    repo = Repo(repo_root)
2202e5b6d6dSopenharmony_ci    [a, b] = splitRevRange(rev_range)
2212e5b6d6dSopenharmony_ci    return repo.merge_base(a, b)[0]
2222e5b6d6dSopenharmony_ci
2232e5b6d6dSopenharmony_cidef get_jira_instance(jira_hostname, jira_username, jira_password, **kwargs):
2242e5b6d6dSopenharmony_ci    jira_url = "https://%s" % jira_hostname
2252e5b6d6dSopenharmony_ci    if jira_username and jira_password:
2262e5b6d6dSopenharmony_ci        jira = JIRA(jira_url, basic_auth=(jira_username, jira_password))
2272e5b6d6dSopenharmony_ci    else:
2282e5b6d6dSopenharmony_ci        jira = JIRA(jira_url)
2292e5b6d6dSopenharmony_ci    return (jira_url, jira)
2302e5b6d6dSopenharmony_ci
2312e5b6d6dSopenharmony_cidef make_icu_issue(jira_issue):
2322e5b6d6dSopenharmony_ci    """Yields an ICUIssue for the individual jira object"""
2332e5b6d6dSopenharmony_ci    commit_wanted = make_commit_wanted(jira_issue)
2342e5b6d6dSopenharmony_ci    return ICUIssue(jira_issue.key, jira_issue.fields.status.id == S_DONE, commit_wanted, jira_issue)
2352e5b6d6dSopenharmony_ci
2362e5b6d6dSopenharmony_ci
2372e5b6d6dSopenharmony_cidef get_jira_issues(jira_query, **kwargs):
2382e5b6d6dSopenharmony_ci    """
2392e5b6d6dSopenharmony_ci    Yields an ICUIssue for each issue in the user-specified query.
2402e5b6d6dSopenharmony_ci    """
2412e5b6d6dSopenharmony_ci    jira_url, jira = get_jira_instance(**kwargs)
2422e5b6d6dSopenharmony_ci    # Jira limits us to query the API using a limited batch size.
2432e5b6d6dSopenharmony_ci    start = 0
2442e5b6d6dSopenharmony_ci    batch_size = 100 # https://jira.atlassian.com/browse/JRACLOUD-67570
2452e5b6d6dSopenharmony_ci    while True:
2462e5b6d6dSopenharmony_ci        issues = jira.search_issues(jira_query, startAt=start, maxResults=batch_size)
2472e5b6d6dSopenharmony_ci        if len(issues) > 0:
2482e5b6d6dSopenharmony_ci            print("Loaded issues %d-%d" % (start + 1, start + len(issues)), file=sys.stderr)
2492e5b6d6dSopenharmony_ci        else:
2502e5b6d6dSopenharmony_ci            print(":warning: No issues matched the query.") # leave this as a warning
2512e5b6d6dSopenharmony_ci        for jira_issue in issues:
2522e5b6d6dSopenharmony_ci            yield make_icu_issue(jira_issue)
2532e5b6d6dSopenharmony_ci        if len(issues) < batch_size:
2542e5b6d6dSopenharmony_ci            break
2552e5b6d6dSopenharmony_ci        start += batch_size
2562e5b6d6dSopenharmony_ci
2572e5b6d6dSopenharmony_cijira_issue_map = dict() # loaded in main()
2582e5b6d6dSopenharmony_ci
2592e5b6d6dSopenharmony_cidef get_single_jira_issue(issue_id, **kwargs):
2602e5b6d6dSopenharmony_ci    """
2612e5b6d6dSopenharmony_ci    Returns a single ICUIssue for the given issue ID.
2622e5b6d6dSopenharmony_ci    This can always be used (in- or out- of query issues), because it
2632e5b6d6dSopenharmony_ci    uses the jira_issue_map as the backing store.
2642e5b6d6dSopenharmony_ci    """
2652e5b6d6dSopenharmony_ci    if issue_id in jira_issue_map:
2662e5b6d6dSopenharmony_ci        # print("Cache hit: issue %s " % (issue_id), file=sys.stderr)
2672e5b6d6dSopenharmony_ci        return jira_issue_map[issue_id]
2682e5b6d6dSopenharmony_ci    jira_url, jira = get_jira_instance(**kwargs)
2692e5b6d6dSopenharmony_ci    jira_issue = jira.issue(issue_id)
2702e5b6d6dSopenharmony_ci    # singleCount = singleCount + 1
2712e5b6d6dSopenharmony_ci    if jira_issue:
2722e5b6d6dSopenharmony_ci        icu_issue = make_icu_issue(jira_issue)
2732e5b6d6dSopenharmony_ci    else:
2742e5b6d6dSopenharmony_ci        icu_issue = None
2752e5b6d6dSopenharmony_ci    jira_issue_map[issue_id] = icu_issue
2762e5b6d6dSopenharmony_ci    print("Loaded single issue %s (%d in cache) " % (issue_id, len(jira_issue_map)), file=sys.stderr)
2772e5b6d6dSopenharmony_ci    return icu_issue
2782e5b6d6dSopenharmony_ci
2792e5b6d6dSopenharmony_cidef toplink():
2802e5b6d6dSopenharmony_ci    print("[�Top](#table-of-contents)")
2812e5b6d6dSopenharmony_ci    print()
2822e5b6d6dSopenharmony_ci
2832e5b6d6dSopenharmony_cidef sectionToFragment(section):
2842e5b6d6dSopenharmony_ci    return re.sub(r' ', '-', section.lower())
2852e5b6d6dSopenharmony_ci
2862e5b6d6dSopenharmony_ci# def aname(section):
2872e5b6d6dSopenharmony_ci#     """convert section name to am anchor"""
2882e5b6d6dSopenharmony_ci#     return "<a name=\"%s\"></a>" % sectionToFragment(section)
2892e5b6d6dSopenharmony_ci
2902e5b6d6dSopenharmony_cidef print_sectionheader(section):
2912e5b6d6dSopenharmony_ci    """Print a section (###) header, including anchor"""
2922e5b6d6dSopenharmony_ci    print("### %s" % (section))
2932e5b6d6dSopenharmony_ci    #print("### %s%s" % (aname(section), section))
2942e5b6d6dSopenharmony_ci
2952e5b6d6dSopenharmony_cidef main():
2962e5b6d6dSopenharmony_ci    args = flag_parser.parse_args()
2972e5b6d6dSopenharmony_ci    print("TIP: Have you pulled the latest main? This script only looks at local commits.", file=sys.stderr)
2982e5b6d6dSopenharmony_ci    if not args.jira_username or not args.jira_password:
2992e5b6d6dSopenharmony_ci        print("WARNING: Jira credentials not supplied. Sensitive tickets will not be found.", file=sys.stderr)
3002e5b6d6dSopenharmony_ci        authenticated = False
3012e5b6d6dSopenharmony_ci    else:
3022e5b6d6dSopenharmony_ci        authenticated = True
3032e5b6d6dSopenharmony_ci
3042e5b6d6dSopenharmony_ci    # exclude these, already merged to old maint
3052e5b6d6dSopenharmony_ci    excludeAlreadyMergedToOldMaint = get_cherrypicked_commits(**vars(args))
3062e5b6d6dSopenharmony_ci
3072e5b6d6dSopenharmony_ci    commits = list(get_commits(**vars(args)))
3082e5b6d6dSopenharmony_ci    issues = list(get_jira_issues(**vars(args)))
3092e5b6d6dSopenharmony_ci
3102e5b6d6dSopenharmony_ci    # commit_issue_ids is all commits in the git query. Excluding cherry exclusions.
3112e5b6d6dSopenharmony_ci    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)
3122e5b6d6dSopenharmony_ci    # which issues have commits that were excluded
3132e5b6d6dSopenharmony_ci    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)
3142e5b6d6dSopenharmony_ci
3152e5b6d6dSopenharmony_ci    # grouped_commits is all commits and issue_ids in the git query, regardless of issue status
3162e5b6d6dSopenharmony_ci    # but NOT including cherry exclusions
3172e5b6d6dSopenharmony_ci    grouped_commits = [
3182e5b6d6dSopenharmony_ci        (issue_id, [commit for commit in commits if commit.issue_id == issue_id and commit.commit.hexsha not in excludeAlreadyMergedToOldMaint])
3192e5b6d6dSopenharmony_ci        for issue_id in sorted(commit_issue_ids)
3202e5b6d6dSopenharmony_ci    ]
3212e5b6d6dSopenharmony_ci    # add all queried issues to the cache
3222e5b6d6dSopenharmony_ci    for issue in issues:
3232e5b6d6dSopenharmony_ci        jira_issue_map[issue.issue_id] = issue
3242e5b6d6dSopenharmony_ci    # only the issue ids in-query
3252e5b6d6dSopenharmony_ci    jira_issue_ids = set(issue.issue_id for issue in issues)
3262e5b6d6dSopenharmony_ci    # only the closed issue ids in-query
3272e5b6d6dSopenharmony_ci    closed_jira_issue_ids = set(issue.issue_id for issue in issues if issue.is_closed)
3282e5b6d6dSopenharmony_ci
3292e5b6d6dSopenharmony_ci    # keep track of issues that we already said have no commit.
3302e5b6d6dSopenharmony_ci    no_commit_ids = set()
3312e5b6d6dSopenharmony_ci
3322e5b6d6dSopenharmony_ci    # constants for the section names.
3332e5b6d6dSopenharmony_ci    CLOSED_NO_COMMIT = "Closed Issues with No Commit"
3342e5b6d6dSopenharmony_ci    CLOSED_ILLEGAL_RESOLUTION = "Closed Issues with Illegal Resolution or Commit"
3352e5b6d6dSopenharmony_ci    COMMIT_NO_JIRA = "Commits without Jira Issue Tag"
3362e5b6d6dSopenharmony_ci    COMMIT_OPEN_JIRA = "Commits with Open Jira Issue"
3372e5b6d6dSopenharmony_ci    COMMIT_JIRA_NOT_IN_QUERY = "Commits with Jira Issue Not Found"
3382e5b6d6dSopenharmony_ci    ISSUE_UNDER_REVIEW = "Issue is under Review"
3392e5b6d6dSopenharmony_ci
3402e5b6d6dSopenharmony_ci    total_problems = 0
3412e5b6d6dSopenharmony_ci    if not args.nocopyright:
3422e5b6d6dSopenharmony_ci        print("<!--")
3432e5b6d6dSopenharmony_ci        print("Copyright (C) 2021 and later: Unicode, Inc. and others.")
3442e5b6d6dSopenharmony_ci        print("License & terms of use: http://www.unicode.org/copyright.html")
3452e5b6d6dSopenharmony_ci        print("-->")
3462e5b6d6dSopenharmony_ci
3472e5b6d6dSopenharmony_ci    print("Commit Report")
3482e5b6d6dSopenharmony_ci    print("=============")
3492e5b6d6dSopenharmony_ci    print()
3502e5b6d6dSopenharmony_ci    print("Environment:")
3512e5b6d6dSopenharmony_ci    print("- Now: %s" % datetime.datetime.now().isoformat())
3522e5b6d6dSopenharmony_ci    print("- Latest Commit: %s/commit/%s" % (args.github_url, commits[0].commit.hexsha))
3532e5b6d6dSopenharmony_ci    print("- Jira Query: `%s`" % args.jira_query)
3542e5b6d6dSopenharmony_ci    print("- Rev Range: `%s`" % args.rev_range)
3552e5b6d6dSopenharmony_ci    print("- Authenticated: %s" % ("`Yes`" if authenticated else "`No` (sensitive tickets not shown)"))
3562e5b6d6dSopenharmony_ci    print()
3572e5b6d6dSopenharmony_ci    print("## Table Of Contents")
3582e5b6d6dSopenharmony_ci    for section in [CLOSED_NO_COMMIT, CLOSED_ILLEGAL_RESOLUTION, COMMIT_NO_JIRA, COMMIT_JIRA_NOT_IN_QUERY, COMMIT_OPEN_JIRA, ISSUE_UNDER_REVIEW]:
3592e5b6d6dSopenharmony_ci        print("- [%s](#%s)" % (section, sectionToFragment(section)))
3602e5b6d6dSopenharmony_ci    print()
3612e5b6d6dSopenharmony_ci    print("## Problem Categories")
3622e5b6d6dSopenharmony_ci    print_sectionheader(CLOSED_NO_COMMIT)
3632e5b6d6dSopenharmony_ci    toplink()
3642e5b6d6dSopenharmony_ci    print("Tip: Tickets with type 'Task' or 'User Guide' or resolution 'Fixed by Other Ticket' are ignored.")
3652e5b6d6dSopenharmony_ci    print()
3662e5b6d6dSopenharmony_ci    found = False
3672e5b6d6dSopenharmony_ci    for issue in issues:
3682e5b6d6dSopenharmony_ci        if not issue.is_closed:
3692e5b6d6dSopenharmony_ci            continue
3702e5b6d6dSopenharmony_ci        if issue.issue_id in commit_issue_ids:
3712e5b6d6dSopenharmony_ci            continue
3722e5b6d6dSopenharmony_ci        if issue.commit_wanted == CommitWanted["OPTIONAL"] or issue.commit_wanted == CommitWanted["FORBIDDEN"]:
3732e5b6d6dSopenharmony_ci            continue
3742e5b6d6dSopenharmony_ci        found = True
3752e5b6d6dSopenharmony_ci        total_problems += 1
3762e5b6d6dSopenharmony_ci        no_commit_ids.add(issue.issue_id)
3772e5b6d6dSopenharmony_ci        pretty_print_issue(issue, type=CLOSED_NO_COMMIT, **vars(args))
3782e5b6d6dSopenharmony_ci        if issue.issue_id in excluded_commit_issue_ids:
3792e5b6d6dSopenharmony_ci            print("\t - **Note: Has cherry-picked commits. Fix Version may be wrong.**")
3802e5b6d6dSopenharmony_ci        print()
3812e5b6d6dSopenharmony_ci    if not found:
3822e5b6d6dSopenharmony_ci        print("*Success: No problems in this category!*")
3832e5b6d6dSopenharmony_ci
3842e5b6d6dSopenharmony_ci    print_sectionheader(CLOSED_ILLEGAL_RESOLUTION)
3852e5b6d6dSopenharmony_ci    toplink()
3862e5b6d6dSopenharmony_ci    print("Tip: Fixed tickets should have resolution 'Fixed by Other Ticket' or 'Fixed'.")
3872e5b6d6dSopenharmony_ci    print("Duplicate tickets should have their fixVersion tag removed.")
3882e5b6d6dSopenharmony_ci    print("Tickets with resolution 'Fixed by Other Ticket' are not allowed to have commits.")
3892e5b6d6dSopenharmony_ci    print()
3902e5b6d6dSopenharmony_ci    found = False
3912e5b6d6dSopenharmony_ci    for issue in issues:
3922e5b6d6dSopenharmony_ci        if not issue.is_closed:
3932e5b6d6dSopenharmony_ci            continue
3942e5b6d6dSopenharmony_ci        if issue.commit_wanted == CommitWanted["OPTIONAL"]:
3952e5b6d6dSopenharmony_ci            continue
3962e5b6d6dSopenharmony_ci        if issue.issue_id in commit_issue_ids and issue.commit_wanted == CommitWanted["REQUIRED"]:
3972e5b6d6dSopenharmony_ci            continue
3982e5b6d6dSopenharmony_ci        if issue.issue_id not in commit_issue_ids and issue.commit_wanted == CommitWanted["FORBIDDEN"]:
3992e5b6d6dSopenharmony_ci            continue
4002e5b6d6dSopenharmony_ci        if issue.issue_id in no_commit_ids:
4012e5b6d6dSopenharmony_ci            continue # we already complained about it above. don't double count.
4022e5b6d6dSopenharmony_ci        found = True
4032e5b6d6dSopenharmony_ci        total_problems += 1
4042e5b6d6dSopenharmony_ci        pretty_print_issue(issue, type=CLOSED_ILLEGAL_RESOLUTION, **vars(args))
4052e5b6d6dSopenharmony_ci        if issue.issue_id not in commit_issue_ids and issue.commit_wanted == CommitWanted["REQUIRED"]:
4062e5b6d6dSopenharmony_ci            print("\t- No commits, and they are REQUIRED.")
4072e5b6d6dSopenharmony_ci        if issue.issue_id in commit_issue_ids and issue.commit_wanted == CommitWanted["FORBIDDEN"]:
4082e5b6d6dSopenharmony_ci            print("\t- Has commits, and they are FORBIDDEN.")
4092e5b6d6dSopenharmony_ci        print()
4102e5b6d6dSopenharmony_ci    if not found:
4112e5b6d6dSopenharmony_ci        print("*Success: No problems in this category!*")
4122e5b6d6dSopenharmony_ci
4132e5b6d6dSopenharmony_ci    # TODO: This section should usually be empty due to the PR checker.
4142e5b6d6dSopenharmony_ci    # Pre-calculate the count and omit it.
4152e5b6d6dSopenharmony_ci    print()
4162e5b6d6dSopenharmony_ci    print_sectionheader(COMMIT_NO_JIRA)
4172e5b6d6dSopenharmony_ci    toplink()
4182e5b6d6dSopenharmony_ci    print("Tip: If you see your name here, make sure to label your commits correctly in the future.")
4192e5b6d6dSopenharmony_ci    print()
4202e5b6d6dSopenharmony_ci    found = False
4212e5b6d6dSopenharmony_ci    for commit in commits:
4222e5b6d6dSopenharmony_ci        if commit.issue_id is not None:
4232e5b6d6dSopenharmony_ci            continue
4242e5b6d6dSopenharmony_ci        found = True
4252e5b6d6dSopenharmony_ci        total_problems += 1
4262e5b6d6dSopenharmony_ci        pretty_print_commit(commit, type=COMMIT_NO_JIRA, **vars(args))
4272e5b6d6dSopenharmony_ci        print()
4282e5b6d6dSopenharmony_ci    if not found:
4292e5b6d6dSopenharmony_ci        print("*Success: No problems in this category!*")
4302e5b6d6dSopenharmony_ci
4312e5b6d6dSopenharmony_ci    print()
4322e5b6d6dSopenharmony_ci    print_sectionheader(COMMIT_JIRA_NOT_IN_QUERY)
4332e5b6d6dSopenharmony_ci    toplink()
4342e5b6d6dSopenharmony_ci    print("Tip: Check that these tickets have the correct fixVersion tag.")
4352e5b6d6dSopenharmony_ci    print()
4362e5b6d6dSopenharmony_ci    found = False
4372e5b6d6dSopenharmony_ci    for issue_id, commits in grouped_commits:
4382e5b6d6dSopenharmony_ci        if issue_id in jira_issue_ids:
4392e5b6d6dSopenharmony_ci            continue
4402e5b6d6dSopenharmony_ci        found = True
4412e5b6d6dSopenharmony_ci        total_problems += 1
4422e5b6d6dSopenharmony_ci        print("#### Issue %s" % issue_id)
4432e5b6d6dSopenharmony_ci        print()
4442e5b6d6dSopenharmony_ci        print("_issue was not found in `%s`_" % args.jira_query) # TODO: link to query?
4452e5b6d6dSopenharmony_ci        jira_issue = get_single_jira_issue(issue_id, **vars(args))
4462e5b6d6dSopenharmony_ci        if jira_issue:
4472e5b6d6dSopenharmony_ci            pretty_print_issue(jira_issue, **vars(args))
4482e5b6d6dSopenharmony_ci        else:
4492e5b6d6dSopenharmony_ci            print("*Jira issue does not seem to exist*")
4502e5b6d6dSopenharmony_ci        print()
4512e5b6d6dSopenharmony_ci        print("##### Commits with Issue %s" % issue_id)
4522e5b6d6dSopenharmony_ci        print()
4532e5b6d6dSopenharmony_ci        for commit in commits:
4542e5b6d6dSopenharmony_ci            if(commit.commit.hexsha in excludeAlreadyMergedToOldMaint):
4552e5b6d6dSopenharmony_ci                print("@@@ ALREADY MERGED")
4562e5b6d6dSopenharmony_ci            pretty_print_commit(commit, **vars(args))
4572e5b6d6dSopenharmony_ci            print()
4582e5b6d6dSopenharmony_ci    if not found:
4592e5b6d6dSopenharmony_ci        print("*Success: No problems in this category!*")
4602e5b6d6dSopenharmony_ci
4612e5b6d6dSopenharmony_ci    print()
4622e5b6d6dSopenharmony_ci
4632e5b6d6dSopenharmony_ci    # list of issues that are in review
4642e5b6d6dSopenharmony_ci    issues_in_review = set()
4652e5b6d6dSopenharmony_ci
4662e5b6d6dSopenharmony_ci    print_sectionheader(COMMIT_OPEN_JIRA)
4672e5b6d6dSopenharmony_ci    toplink()
4682e5b6d6dSopenharmony_ci    print("Tip: Consider closing the ticket if it is fixed.")
4692e5b6d6dSopenharmony_ci    print()
4702e5b6d6dSopenharmony_ci    found = False
4712e5b6d6dSopenharmony_ci    componentToTicket = {}
4722e5b6d6dSopenharmony_ci    def addToComponent(component, issue_id):
4732e5b6d6dSopenharmony_ci        if component not in componentToTicket:
4742e5b6d6dSopenharmony_ci            componentToTicket[component] = set()
4752e5b6d6dSopenharmony_ci        componentToTicket[component].add(issue_id)
4762e5b6d6dSopenharmony_ci    # first, scan ahead for the components
4772e5b6d6dSopenharmony_ci    for issue_id, commits in grouped_commits:
4782e5b6d6dSopenharmony_ci        if issue_id in closed_jira_issue_ids:
4792e5b6d6dSopenharmony_ci            continue
4802e5b6d6dSopenharmony_ci        jira_issue = get_single_jira_issue(issue_id, **vars(args))
4812e5b6d6dSopenharmony_ci        if jira_issue and jira_issue.is_closed:
4822e5b6d6dSopenharmony_ci            # JIRA ticket was not in query, but was actually closed.
4832e5b6d6dSopenharmony_ci            continue
4842e5b6d6dSopenharmony_ci        if jira_issue_under_review(jira_issue):
4852e5b6d6dSopenharmony_ci            print("skipping for now- %s is under review" % issue_id, file=sys.stderr)
4862e5b6d6dSopenharmony_ci            issues_in_review.add(issue_id)
4872e5b6d6dSopenharmony_ci            continue
4882e5b6d6dSopenharmony_ci        # OK. Now, split it out by component
4892e5b6d6dSopenharmony_ci        if jira_issue.issue.fields.components and len(jira_issue.issue.fields.components) > 0:
4902e5b6d6dSopenharmony_ci            for component in jira_issue.issue.fields.components:
4912e5b6d6dSopenharmony_ci                addToComponent(component.name, issue_id)
4922e5b6d6dSopenharmony_ci        else:
4932e5b6d6dSopenharmony_ci            addToComponent("(no component)", issue_id)
4942e5b6d6dSopenharmony_ci
4952e5b6d6dSopenharmony_ci    print("#### Open Issues by Component")
4962e5b6d6dSopenharmony_ci    print()
4972e5b6d6dSopenharmony_ci    for component in sorted(componentToTicket.keys()):
4982e5b6d6dSopenharmony_ci        print(" - **%s**: %s" % (component,  ' '.join("[%s](#issue-%s)" % (issue_id, sectionToFragment(issue_id)) for issue_id in componentToTicket[component])))
4992e5b6d6dSopenharmony_ci
5002e5b6d6dSopenharmony_ci    print()
5012e5b6d6dSopenharmony_ci    print()
5022e5b6d6dSopenharmony_ci
5032e5b6d6dSopenharmony_ci    # now, actually show the ticket list.
5042e5b6d6dSopenharmony_ci    for issue_id, commits in grouped_commits:
5052e5b6d6dSopenharmony_ci        if issue_id in closed_jira_issue_ids:
5062e5b6d6dSopenharmony_ci            continue
5072e5b6d6dSopenharmony_ci        jira_issue = get_single_jira_issue(issue_id, **vars(args))
5082e5b6d6dSopenharmony_ci        if jira_issue and jira_issue.is_closed:
5092e5b6d6dSopenharmony_ci            # JIRA ticket was not in query, but was actually closed.
5102e5b6d6dSopenharmony_ci            continue
5112e5b6d6dSopenharmony_ci        if jira_issue_under_review(jira_issue):
5122e5b6d6dSopenharmony_ci            # We already added it to the review list above.
5132e5b6d6dSopenharmony_ci            continue
5142e5b6d6dSopenharmony_ci        print("#### Issue %s" % issue_id)
5152e5b6d6dSopenharmony_ci        print()
5162e5b6d6dSopenharmony_ci        print("_Jira issue is open_")
5172e5b6d6dSopenharmony_ci        if jira_issue:
5182e5b6d6dSopenharmony_ci            pretty_print_issue(jira_issue, **vars(args))
5192e5b6d6dSopenharmony_ci        else:
5202e5b6d6dSopenharmony_ci            print("*Jira issue does not seem to exist*")
5212e5b6d6dSopenharmony_ci        print()
5222e5b6d6dSopenharmony_ci        print("##### Commits with Issue %s" % issue_id)
5232e5b6d6dSopenharmony_ci        print()
5242e5b6d6dSopenharmony_ci        found = True
5252e5b6d6dSopenharmony_ci        total_problems += 1
5262e5b6d6dSopenharmony_ci        for commit in commits:
5272e5b6d6dSopenharmony_ci            # print("@@@@ %s = %s / %s" % (issue_id, commit, commit.commit.summary), file=sys.stderr)
5282e5b6d6dSopenharmony_ci            pretty_print_commit(commit, **vars(args))
5292e5b6d6dSopenharmony_ci            print()
5302e5b6d6dSopenharmony_ci    if not found:
5312e5b6d6dSopenharmony_ci        print("*Success: No problems in this category!*")
5322e5b6d6dSopenharmony_ci
5332e5b6d6dSopenharmony_ci    print_sectionheader(ISSUE_UNDER_REVIEW)
5342e5b6d6dSopenharmony_ci    print()
5352e5b6d6dSopenharmony_ci    toplink()
5362e5b6d6dSopenharmony_ci    print("These issues are otherwise accounted for above, but are in review.")
5372e5b6d6dSopenharmony_ci    for issue_id in sorted(issues_in_review):
5382e5b6d6dSopenharmony_ci        jira_issue = get_single_jira_issue(issue_id, **vars(args))
5392e5b6d6dSopenharmony_ci        pretty_print_issue(jira_issue, type=ISSUE_UNDER_REVIEW, **vars(args))
5402e5b6d6dSopenharmony_ci
5412e5b6d6dSopenharmony_ci    print()
5422e5b6d6dSopenharmony_ci    print("## Total Problems: %s" % total_problems)
5432e5b6d6dSopenharmony_ci    print("## Issues under review: %s" % len(issues_in_review)) # not counted as a problem.
5442e5b6d6dSopenharmony_ci
5452e5b6d6dSopenharmony_ciif __name__ == "__main__":
5462e5b6d6dSopenharmony_ci    main()
547