Skip to content
120 changes: 110 additions & 10 deletions did/plugins/jira.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Jira stats such as created, updated or resolved issues
Jira stats such as created, updated or resolved issues, and worklogs

Configuration example (token)::

Expand Down Expand Up @@ -33,6 +33,13 @@
Name of the issue status we want to report transitions to.
Defaults to ``Release Pending`` (marking "verified" issues).

worklog_enable
Whether or not to fetch worklogs. Default: off.

worklog_show_time_spent
Whether or not to show how much time was recorded for each
worklog. (Has no effect when ``worklog_enable`` is ``off``).

Configuration example (GSS authentication)::

[issues]
Expand Down Expand Up @@ -141,6 +148,10 @@ def __init__(self, issue=None, parent=None):
self.key = issue["key"]
self.summary = issue["fields"]["summary"]
self.comments = issue["fields"]["comment"]["comments"]
self.worklogs = []
if "worklog" in issue["fields"]:
worklog_data = issue["fields"].get("worklog", {})
self.worklogs = worklog_data.get("worklogs", [])
if "changelog" in issue:
self.histories = issue["changelog"]["histories"]
else:
Expand All @@ -154,32 +165,54 @@ def __init__(self, issue=None, parent=None):

def __str__(self):
""" Jira key and summary for displaying """
res = ""
label = f"{self.prefix}-{self.identifier}"
worklogs = ""
for worklog in self.worklogs:
created = dateutil.parser.parse(
worklog["created"]).strftime('%A, %B %d, %Y')
worklogs += "\n\n"
time_spent = ""
if self.parent.worklog_show_time_spent:
time_spent_value = worklog.get('timeSpent', '')
if time_spent_value:
time_spent = f" ({time_spent_value})"

worklogs += f" * Worklog: {created}{time_spent}\n\n"
comment = worklog.get("comment", "")
if comment:
worklogs += "\n".join(
[f" {line}" for line in comment.splitlines()])
if self.options.format == "markdown":
href = f"{self.parent.url}/browse/{self.issue['key']}"
return f"[{label}]({href}) - {self.summary}"
return f"{label} - {self.summary}"
res = f"[{label}]({href}) - {self.summary}"
else:
res = f"{label} - {self.summary}"

return res + worklogs

def __eq__(self, other):
""" Compare issues by key """
return self.key == other.key

@staticmethod
def search(query, stats, expand="", timeout=TIMEOUT):
def search(query, stats, expand="", timeout=TIMEOUT, with_worklog=False):
""" Perform issue search for given stats instance """
log.debug("Search query: %s", query)
issues = []
# Fetch data from the server in batches of MAX_RESULTS issues
fields = "summary,comment"
if with_worklog:
fields += ",worklog"
for batch in range(MAX_BATCHES):
encoded_query = urllib.parse.urlencode(
{
"jql": query,
"fields": "summary,comment",
"fields": fields,
"maxResults": MAX_RESULTS,
"startAt": batch * MAX_RESULTS,
"expand": expand
}
)
"startAt": batch *
MAX_RESULTS,
"expand": expand})
current_url = f"{stats.parent.url}/rest/api/latest/search?{encoded_query}"
log.debug("Fetching %s", current_url)
while True:
Expand Down Expand Up @@ -425,6 +458,41 @@ def fetch(self):
query = query + f" AND project in ({self.parent.project})"
self.stats = Issue.search(query, stats=self)


class JiraWorklog(Stats):
""" Jira Issues for which a worklog entry was made """

def fetch(self):
log.info(
"[%s] Searching for issues for which work was logged by '%s'",
self.option,
self.user.login or self.user.email)
query = (
f"worklogAuthor = '{self.user.login or self.user.email}' "
f"and worklogDate >= {self.options.since} "
f"and worklogDate < {self.options.until} "
)
if self.parent.project:
query = query + f" AND project in ({self.parent.project})"
issues = Issue.search(query, stats=self, with_worklog=True)
# Now we have just the issues which have work logs but we
# want to limit what worklogs we include in the report.
# Filter out worklogs that were not done in the given
# time frame.
log.debug("Found issues: %d", len(issues))
for issue in issues:
log.debug("Found worklogs: %s", len(issue.worklogs))
issue.worklogs = [wl for wl in issue.worklogs if
(("name" in wl["author"]
and wl["author"]["name"] == self.user.login)
or ("emailAddress" in wl["author"]
and wl["author"]["emailAddress"] == self.user.email))
and self.options.since.date <=
dateutil.parser.parse(wl["created"]).date()
< self.options.until.date]
log.debug("Num worklogs after filtering: %d", len(issue.worklogs))
self.stats = [issue for issue in issues if len(issue.worklogs) > 0]

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Stats Group
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -496,6 +564,8 @@ def _handle_scriptrunner(self, config):
"When scriptrunner is disabled with 'use_scriptrunner=False', "
"'project' has to be defined for each JIRA section.")

# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
def __init__(self, option, name=None, parent=None, user=None):
StatsGroup.__init__(self, option, name, parent, user)
self._session = None
Expand Down Expand Up @@ -547,6 +617,31 @@ def __init__(self, option, name=None, parent=None, user=None):
# State transition to count
self.transition_status = config.get("transition_status", DEFAULT_TRANSITION_TO)

if "worklog_enable" in config:
try:
self.worklog_enable = strtobool(
config["worklog_enable"])
except Exception as error:
raise ReportError(
f"Error when parsing 'worklog_enable': {error}") from error
else:
self.worklog_enable = False

if "worklog_show_time_spent" in config:
try:
self.worklog_show_time_spent = strtobool(
config["worklog_show_time_spent"])
except Exception as error:
raise ReportError(
f"Error when parsing 'worklog_show_time_spent': {error}") from error
else:
self.worklog_show_time_spent = True

if not self.worklog_enable and self.worklog_show_time_spent:
log.debug(
"'worklog_show_time_spent' is on but has no effect "
"because 'worklog_enable' is off")

# Create the list of stats
self.stats = [
JiraCreated(
Expand All @@ -569,9 +664,14 @@ def __init__(self, option, name=None, parent=None, user=None):
name=f"Issues updated in {option}"),
JiraTransition(
option=option + "-transitioned", parent=self,
name=f"Issues transitioned in {option}"),
name=f"Issues transitioned in {option}")
]

if self.worklog_enable:
self.stats.append(JiraWorklog(
option=f"{option}-worklog", parent=self,
name=f"Issues with worklogs in {option}"))

def _basic_auth_session(self):
log.debug("Connecting to %s for basic auth", self.auth_url)
basic_auth = (self.auth_username, self.auth_password)
Expand Down
133 changes: 132 additions & 1 deletion tests/plugins/test_jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@

import logging
import os
import re
import tempfile

import pytest
from _pytest.logging import LogCaptureFixture

import did.base
import did.cli
from did.plugins.jira import JiraStats
from did.plugins.jira import JiraStats, JiraWorklog

CONFIG = """
[general]
Expand Down Expand Up @@ -253,3 +254,133 @@ def assert_conf_error(config, expected_error=did.base.ReportError):
did.base.Config(config)
with pytest.raises(expected_error):
JiraStats("jira")


def has_worklog_stat() -> bool:
""" Returns true if a fresh initiated JiraStats
object would have a JiraWorklog object """
stats = JiraStats("jira")
for stat in stats.stats:
if isinstance(stat, JiraWorklog):
return True
return False


@pytest.mark.parametrize( # type: ignore[misc]
("config_str", "expected_worklog_enable"),
[
(f"""
{CONFIG}
""", False),
(f"""
{CONFIG}
worklog_enable = on
""", True),
(f"""
{CONFIG}
worklog_enable = off
""", False),
],
)
def test_worklog_enabled(
config_str: str,
expected_worklog_enable: bool,
) -> None:
""" Tests default and explicit behaviour for worklog_enable """
did.base.Config(config_str)
assert has_worklog_stat() == expected_worklog_enable


def get_named_stat(options: str):
"""
Retrieve the statistics by option name.
"""
for stat in did.cli.main(f"{options}")[0][0].stats[0].stats:
if stat.option in options:
assert stat.stats is not None
return stat.stats
pytest.fail(reason=f"No stat found with options {options}")
return None


def test_worklog_against_real_jira_instance() -> None:
""" Check that worklogs are printed for matching issues """
# I've searched a public Jira instance and found this issue
# that has worklog entries:
# https://issues.apache.org/jira/browse/HIVE-21563
# The user "githubbot" has more entries in other issues
# that we've limited to certain day.
did.base.Config("""
[general]
email = [email protected]
width = 500
[jira]
type = jira
prefix = HIVE
project = HIVE
login = githubbot
url = https://issues.apache.org/jira/
worklog_enable = on
""")
options = "--jira-worklog --since 2021-05-07 --until 2021-05-07 --verbose"
stats = get_named_stat(options)
expectations = [
{
"id": "HIVE-25095",
"worklog_snippets": [
"* Worklog: Friday, May 07, 2021 (10m)",
"ujc714 opened a new pull request #2255:",
]
},
{
"id": "HIVE-25089",
"worklog_snippets": [
"* Worklog: Friday, May 07, 2021 (10m)",
"kasakrisz merged pull request #2241:",
]},
{
"id": "HIVE-25071",
"worklog_snippets": [
"* Worklog: Friday, May 07, 2021 (10m)",
"kasakrisz commented on a change in pull request #2231:",
]},
{
"id": "HIVE-25046",
"worklog_snippets": [
"* Worklog: Friday, May 07, 2021 (10m)",
"zabetak commented on a change in pull request #2205:"
]
}, {
"id": "HIVE-23756",
"worklog_snippets": [
"* Worklog: Friday, May 07, 2021 (10m)",
"scarlin-cloudera closed pull request #2253:",
"* Worklog: Friday, May 07, 2021 (10m)",
"scarlin-cloudera opened a new pull request #2254:"
]
}, {
"id": "HIVE-21563",
"worklog_snippets": [
"* Worklog: Friday, May 07, 2021 (10m)",
"sunchao merged pull request #2251:",
]},
]
assert len(expectations) == len(stats)
for i, exp in enumerate(expectations):
stat_str = str(stats[i])

# Check that the issue with the given ID was found
id_pattern = f"{exp["id"]} \\S+"
regex = re.compile(id_pattern)
assert regex
assert regex.match(stat_str)

# Check that for each issue we find the expected
# worklog snippets one after the next
start = 0
for worklog_snippet in exp["worklog_snippets"]:
new_start = stat_str.find(worklog_snippet, start)
assert new_start > 0, (f"worklog_snippet '{worklog_snippet}' "
"not found in stat string from position "
"{start}: {stat_str}")
start = new_start