• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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