• 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
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