ICU-20166 Adding commit-checker tool.

This tool checks the integrity of a range of commits, in particular references to the corresponding Jira issues.

Squashed commits:

ICU-20119 Adding first commit report.
ICU-20166 Handling tickets that do not require commits.
ICU-20166 Search Jira issues from extra commits
ICU-20119 Updating commit report.
ICU-20166 Adding copyright to commit checker tools.
ICU-20119 Updating commit report.
ICU-20166 Require ticket at beginning of commit message.
ICU-20119 Updating commit report.
ICU-20119 Updating commit report.
ICU-20119 Updating commit report.
ICU-20120 Updating commit report.
ICU-20166 Removing REPORT.md from source control.
This commit is contained in:
Shane Carr 2018-10-17 13:01:53 -07:00 committed by Shane F. Carr
parent e509105c9b
commit d60ebc020e
4 changed files with 357 additions and 0 deletions

4
tools/commit-checker/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
_old/
.env
Pipfile.lock

View file

@ -0,0 +1,17 @@
# Copyright (C) 2018 and later: Unicode, Inc. and others.
# License & terms of use: http://www.unicode.org/copyright.html
# Author: shane@unicode.org
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[requires]
python_version = "3"
[packages]
gitpython = "*"
jira = "*"
[dev-packages]

View file

@ -0,0 +1,39 @@
<!---
Copyright (C) 2018 and later: Unicode, Inc. and others.
License & terms of use: http://www.unicode.org/copyright.html
-->
# Commit Checker Tool
This tool checks the ICU Git repository against the ICU Jira issue tracker to ensure that the two are consistent with one another.
Author: Shane Carr
## Installation
Install `pipenv` globally:
$ sudo pip3 install pipenv
Install this project's dependencies locally:
$ pipenv install
Optional: save your Jira credentials in a `.env` file in this directory:
JIRA_USERNAME=hello
JIRA_PASSWORD=world
This is required if you want to process sensitive tickets.
## Usage
Make sure you have updated your repository:
$ git pull upstream master
Run the tool and save the result into REPORT.md:
$ pipenv run python3 check.py > REPORT.md
Open a pull request so others can view the report easilly.

View file

@ -0,0 +1,297 @@
#!/usr/bin/env python3
# Copyright (C) 2018 and later: Unicode, Inc. and others.
# License & terms of use: http://www.unicode.org/copyright.html
# Author: shane@unicode.org
import argparse
import itertools
import os
import re
import sys
from enum import Enum
from collections import namedtuple
from git import Repo
from jira import JIRA
ICUCommit = namedtuple("ICUCommit", ["issue_id", "commit"])
class CommitWanted(Enum):
REQUIRED = 1
OPTIONAL = 2
FORBIDDEN = 3
ERROR = 4
ICUIssue = namedtuple("ICUIssue", ["issue_id", "is_closed", "commit_wanted", "issue"])
flag_parser = argparse.ArgumentParser(
description = "Generates a Markdown report for commits on master since the 'latest' tag.",
formatter_class = argparse.ArgumentDefaultsHelpFormatter
)
flag_parser.add_argument(
"--rev-range",
help = "A git revision range; see https://git-scm.com/docs/gitrevisions",
default = "latest..master"
)
flag_parser.add_argument(
"--jira-hostname",
help = "Hostname of the Jira instance",
default = "unicode-org.atlassian.net"
)
flag_parser.add_argument(
"--jira-username",
help = "Username to use for authenticating to Jira",
default = os.environ.get("JIRA_USERNAME", None)
)
flag_parser.add_argument(
"--jira-password",
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\".",
default = os.environ.get("JIRA_PASSWORD", None)
)
flag_parser.add_argument(
"--jira-query",
help = "JQL query to match with tickets.",
default = "project=ICU AND fixVersion=63.1"
)
def issue_id_to_url(issue_id, jira_hostname, **kwargs):
return "https://%s/browse/%s" % (jira_hostname, issue_id)
def pretty_print_commit(commit, **kwargs):
print("- %s `%s`" % (commit.commit.hexsha[:7], commit.commit.summary))
print("\t- Authored by %s <%s>" % (commit.commit.author.name, commit.commit.author.email))
print("\t- Committed at %s" % commit.commit.committed_datetime.isoformat())
print("\t- GitHub Link: %s" % "https://github.com/unicode-org/icu/commit/%s" % commit.commit.hexsha)
def pretty_print_issue(issue, **kwargs):
print("- %s: `%s`" % (issue.issue_id, issue.issue.fields.summary))
print("\t- Assigned to %s" % issue.issue.fields.assignee.displayName)
print("\t- Jira Link: %s" % issue_id_to_url(issue.issue_id, **kwargs))
def get_commits(rev_range, **kwargs):
"""
Yields an ICUCommit for each commit in the user-specified rev-range.
"""
repo_path = os.path.join(os.path.dirname(__file__), "..", "..")
repo = Repo(repo_path)
for commit in repo.iter_commits(rev_range):
match = re.search(r"^(ICU-\d+) ", commit.message)
if match:
yield ICUCommit(match.group(1), commit)
else:
yield ICUCommit(None, commit)
def get_jira_instance(jira_hostname, jira_username, jira_password, **kwargs):
jira_url = "https://%s" % jira_hostname
if jira_username and jira_password:
jira = JIRA(jira_url, basic_auth=(jira_username, jira_password))
else:
jira = JIRA(jira_url)
return (jira_url, jira)
def make_icu_issue(jira_issue):
# Resolution ID 10004 is "Fixed"
# Resolution ID 10015 is "Fixed by Other Ticket"
if not jira_issue.fields.resolution:
commit_wanted = CommitWanted["OPTIONAL"]
elif jira_issue.fields.resolution.id == "10015":
commit_wanted = CommitWanted["FORBIDDEN"]
elif jira_issue.fields.resolution.id != "10004":
commit_wanted = CommitWanted["ERROR"]
# Issue Type ID 10010 is User Guide
# Issue Type ID 10003 is Task
elif jira_issue.fields.issuetype.id == "10010" or jira_issue.fields.issuetype.id == "10003":
commit_wanted = CommitWanted["OPTIONAL"]
else:
commit_wanted = CommitWanted["REQUIRED"]
# Status ID 10002 is "Done"
return ICUIssue(jira_issue.key, jira_issue.fields.status.id == "10002", commit_wanted, jira_issue)
def get_jira_issues(jira_query, **kwargs):
"""
Yields an ICUIssue for each issue in the user-specified query.
"""
jira_url, jira = get_jira_instance(**kwargs)
# Jira limits us to query the API using a limited batch size.
start = 0
batch_size = 50
while True:
issues = jira.search_issues(jira_query, startAt=start, maxResults=batch_size)
print("Loaded issues %d-%d" % (start, start + len(issues)), file=sys.stderr)
for jira_issue in issues:
yield make_icu_issue(jira_issue)
if len(issues) < batch_size:
break
start += batch_size
def get_single_jira_issue(issue_id, **kwargs):
"""
Returns a single ICUIssue for the given issue ID.
"""
jira_url, jira = get_jira_instance(**kwargs)
jira_issue = jira.issue(issue_id)
print("Loaded single issue %s" % issue_id, file=sys.stderr)
if jira_issue:
return make_icu_issue(jira_issue)
else:
return None
def main():
args = flag_parser.parse_args()
print("TIP: Have you pulled the latest master? This script only looks at local commits.", file=sys.stderr)
if not args.jira_username or not args.jira_password:
print("WARNING: Jira credentials not supplied. Sensitive tickets will not be found.", file=sys.stderr)
authenticated = False
else:
authenticated = True
commits = list(get_commits(**vars(args)))
issues = list(get_jira_issues(**vars(args)))
commit_issue_ids = set(commit.issue_id for commit in commits if commit.issue_id is not None)
grouped_commits = [
(issue_id, [commit for commit in commits if commit.issue_id == issue_id])
for issue_id in sorted(commit_issue_ids)
]
jira_issue_ids = set(issue.issue_id for issue in issues)
closed_jira_issue_ids = set(issue.issue_id for issue in issues if issue.is_closed)
total_problems = 0
print("<!---")
print("Copyright (C) 2018 and later: Unicode, Inc. and others.")
print("License & terms of use: http://www.unicode.org/copyright.html")
print("-->")
print()
print("Commit Report")
print("=============")
print()
print("Environment:")
print("- Latest Commit: %s" % commits[0].commit.hexsha)
print("- Jira Query: %s" % args.jira_query)
print("- Authenticated: %s" % "Yes" if authenticated else "No (sensitive tickets not shown)")
print()
print("## Problem Categories")
print("### Closed Issues with No Commit")
print("Tip: Tickets with type 'Task' or 'User Guide' or resolution 'Fixed by Other Ticket' are ignored.")
print()
found = False
for issue in issues:
if not issue.is_closed:
continue
if issue.issue_id in commit_issue_ids:
continue
if issue.commit_wanted == CommitWanted["OPTIONAL"] or issue.commit_wanted == CommitWanted["FORBIDDEN"]:
continue
found = True
total_problems += 1
pretty_print_issue(issue, **vars(args))
print()
if not found:
print("*Success: No problems in this category!*")
print("### Closed Issues with Illegal Resolution or Commit")
print("Tip: Fixed tickets should have resolution 'Fixed by Other Ticket' or 'Fixed'.")
print("Duplicate tickets should have their fixVersion tag removed.")
print("Tickets with resolution 'Fixed by Other Ticket' are not allowed to have commits.")
print()
found = False
for issue in issues:
if not issue.is_closed:
continue
if issue.commit_wanted == CommitWanted["OPTIONAL"]:
continue
if issue.issue_id in commit_issue_ids and issue.commit_wanted == CommitWanted["REQUIRED"]:
continue
if issue.issue_id not in commit_issue_ids and issue.commit_wanted == CommitWanted["FORBIDDEN"]:
continue
found = True
total_problems += 1
pretty_print_issue(issue, **vars(args))
print()
if not found:
print("*Success: No problems in this category!*")
print()
print("### Commits without Jira Issue Tag")
print("Tip: If you see your name here, make sure to label your commits correctly in the future.")
print()
found = False
for commit in commits:
if commit.issue_id is not None:
continue
found = True
total_problems += 1
pretty_print_commit(commit, **vars(args))
print()
if not found:
print("*Success: No problems in this category!*")
print()
print("### Commits with Jira Issue Not Found")
print("Tip: Check that these tickets have the correct fixVersion tag.")
print()
found = False
for issue_id, commits in grouped_commits:
if issue_id in jira_issue_ids:
continue
found = True
total_problems += 1
print("#### Issue %s" % issue_id)
print()
jira_issue = get_single_jira_issue(issue_id, **vars(args))
if jira_issue:
pretty_print_issue(jira_issue, **vars(args))
else:
print("*Jira issue does not seem to exist*")
print()
print("##### Commits with Issue %s" % issue_id)
print()
for commit in commits:
pretty_print_commit(commit, **vars(args))
print()
if not found:
print("*Success: No problems in this category!*")
print()
print("### Commits with Open Jira Issue")
print("Tip: Consider closing the ticket if it is fixed.")
print()
found = False
for issue_id, commits in grouped_commits:
if issue_id in closed_jira_issue_ids:
continue
print("#### Issue %s" % issue_id)
print()
jira_issue = get_single_jira_issue(issue_id, **vars(args))
if jira_issue:
pretty_print_issue(jira_issue, **vars(args))
else:
print("*Jira issue does not seem to exist*")
print()
print("##### Commits with Issue %s" % issue_id)
print()
found = True
total_problems += 1
for commit in commits:
pretty_print_commit(commit, **vars(args))
print()
if not found:
print("*Success: No problems in this category!*")
print()
print("## Total Problems: %s" % total_problems)
if __name__ == "__main__":
main()