Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions did/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,11 @@
elif "month" in argument:
since, until, period = Date.get_month("last" in argument)

elif "sprint" in argument:
# pylint: disable=import-outside-toplevel,cyclic-import

Check notice

Code scanning / CodeQL

Cyclic import Note

Import of module
did.plugins.jira
begins an import cycle.
from did.plugins.jira import get_sprint_dates

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cycle is intentional — base.py needs to dispatch did this sprint to the Jira plugin, and the Jira plugin needs Date from base.py. The import is lazy (inside Date.period(), only executed when the user actually requests a sprint period), so the cycle is broken at runtime. This is the standard Python pattern for unavoidable circular dependencies.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@psss I'm not able to verify if this is a pythonic way to handle this. Can you?

since, until, period = get_sprint_dates("last" in argument)

else: # Default to week
since, until, period = Date.get_week("last" in argument)

Expand Down
2 changes: 1 addition & 1 deletion did/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def check(self) -> None:
'today', 'yesterday', 'monday', 'tuesday', 'wednesday', 'thursday',
'friday', 'saturday', 'sunday',
'this', 'last',
'week', 'month', 'quarter', 'year']
'week', 'month', 'quarter', 'year', 'sprint']
if self.arg is None:
raise RuntimeError("Programming error: call `parse` before `check`")
for argument in self.arg:
Expand Down
153 changes: 153 additions & 0 deletions did/plugins/jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,159 @@
log.debug("Num worklogs after filtering: %d", len(issue.worklogs))
self.stats = [issue for issue in issues if len(issue.worklogs) > 0]

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Sprint Support
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


def get_sprint_dates(last: bool = False) -> tuple:
"""
Fetch sprint dates from Jira Agile API.

Returns (since, until, sprint_name) tuple with the sprint's
date range. If ``last`` is True, returns the most recently
closed sprint; otherwise returns the active sprint.

Auto-discovers the Scrum board from the project config, or
uses the sprint_board config if provided.
"""
# Import Date here to avoid circular dependency

Check notice

Code scanning / CodeQL

Cyclic import Note

Import of module
did.base
begins an import cycle.
# (base.py -> jira.py -> base.py)
# pylint: disable=import-outside-toplevel,too-many-locals
# pylint: disable=too-many-branches,too-many-statements
from did.base import Date

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as in #475 (comment) just from the other end


# Read Jira config
try:
config = dict(Config().section("jira"))
except Exception as error:
raise ReportError(
"No [jira] section found in config. Sprint support requires "
"a configured Jira integration.") from error

jira_group = JiraStatsGroup(option="jira")

# Get board ID - either from config or auto-discover
board_id: Optional[int] = None
if "sprint_board" in config:
try:
board_id = int(config["sprint_board"])
log.debug("Using sprint_board from config: %s", board_id)
except ValueError as error:
raise ReportError(
f"Invalid sprint_board value '{config['sprint_board']}'. "
"Must be a numeric board ID.") from error
else:
# Auto-discover from project
if "project" not in config:
raise ReportError(
"Neither 'sprint_board' nor 'project' is set in [jira] config. "
"Sprint support requires one of them.")

project_key = config["project"].strip()
if "," in project_key:
raise ReportError(
"Multiple projects configured in [jira] config. "
"Set sprint_board to specify which board to use "
"for sprint dates.")
log.debug("Auto-discovering Scrum board for project %s", project_key)

# Query Agile API for boards
boards_url = (
f"{jira_group.url}/rest/agile/1.0/board"
f"?projectKeyOrId={project_key}&type=scrum"
)

try:
response = jira_group.session.get(
boards_url,
timeout=jira_group.timeout)
response.raise_for_status()
boards_data = response.json()
except requests.exceptions.RequestException as error:
raise ReportError(
f"Failed to fetch Scrum boards for project {project_key}: {error}"
) from error

boards = boards_data.get("values", [])
if not boards:
raise ReportError(
f"No Scrum boards found for project {project_key}. "
"Make sure the project has a Scrum board configured, or "
"set sprint_board manually in [jira] config.")

if len(boards) > 1:
board_list = ", ".join(
f"{b['id']} ({b['name']})" for b in boards)
raise ReportError(
f"Multiple Scrum boards found for project {project_key}: "
f"{board_list}. Set sprint_board in [jira] config to choose one.")

board_id = boards[0]["id"]
log.debug("Auto-discovered board ID: %s (%s)",
board_id, boards[0]["name"])

if board_id is None:
# This should never happen due to validation above
raise ReportError("Failed to determine board ID")

# Query for sprints
state = "closed" if last else "active"
sprints_url = (
f"{jira_group.url}/rest/agile/1.0/board/{board_id}/sprint"
f"?state={state}"
)

try:
response = jira_group.session.get(
sprints_url,
timeout=jira_group.timeout)
response.raise_for_status()
sprints_data = response.json()
except requests.exceptions.RequestException as error:
raise ReportError(
f"Failed to fetch {state} sprints for board {board_id}: {error}"
) from error

sprints = sprints_data.get("values", [])
if not sprints:
raise ReportError(
f"No {state} sprints found for board {board_id}.")

# Sort by endDate descending and take the most recent one.
# This handles both "last sprint" (most recently closed) and
# "this sprint" (if parallel sprints are enabled, picks the
# one ending soonest).
sprints_with_dates = [
s for s in sprints
if s.get("endDate")
]
if not sprints_with_dates:
raise ReportError(
f"No {state} sprints with end dates found "
f"for board {board_id}.")
sprints_with_dates.sort(
key=lambda s: dateutil.parser.parse(s["endDate"]),
reverse=True)
sprint = sprints_with_dates[0]

# Parse dates
if "startDate" not in sprint or "endDate" not in sprint:
raise ReportError(
f"Sprint {sprint.get('name', sprint.get('id'))} is missing "
"startDate or endDate.")

start_date = dateutil.parser.parse(sprint["startDate"]).date()
end_date = dateutil.parser.parse(sprint["endDate"]).date()
sprint_name = sprint.get("name", f"Sprint {sprint['id']}")

return (
Date(str(start_date)),
Date(str(end_date)),
sprint_name
)


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Stats Group
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
33 changes: 33 additions & 0 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,39 @@ Each path should be a package or module. This method works whether
the package or module is on the filesystem or in an ``.egg``.


Time Periods
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

In addition to the standard time periods (``today``, ``yesterday``,
``this week``, ``last week``, ``this month``, ``last month``,
``this quarter``, ``last quarter``, ``this year``, ``last year``),
``did`` supports sprint-based reporting when configured with Jira::

did this sprint
did last sprint

Sprint periods automatically query the Jira Agile API to fetch the
date range of your active sprint (for ``this sprint``) or the most
recently closed sprint (for ``last sprint``). All configured stats
(git, GitHub, GitLab, Confluence, etc.) will then report against
that sprint's date range.

Sprint support requires a ``[jira]`` section in your config with at
least a ``project`` or ``sprint_board`` setting. If your project has
multiple Scrum boards, specify which one to use::

[jira]
type = jira
url = https://your-jira.atlassian.net
project = MYPROJECT
sprint_board = 42

The ``sprint_board`` option should be set to the numeric board ID
shown in your Jira board URL. If not specified, ``did`` will
auto-discover the Scrum board from your project. If multiple boards
exist, an error will list their IDs so you can choose the correct one.


Email
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
54 changes: 54 additions & 0 deletions tests/unit/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import pytest

import did.base
import did.cli
import did.plugins.jira

# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Config
Expand Down Expand Up @@ -433,3 +435,55 @@ def test_week_start_config_validation() -> None:
"[general]\nweek_start = invalid\nemail = test@example.com")
with pytest.raises(did.base.ConfigError, match=r"Invalid week_start"):
_ = config.week_start


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Sprint Support Tests
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


@pytest.mark.usefixtures("_mock_today")
def test_sprint_period() -> None:
""" Test 'this sprint' period """
mock_start = did.base.Date("2015-09-21")
mock_end = did.base.Date("2015-10-05")
mock_period = "Sprint 42"

with patch.object(
did.plugins.jira,
'get_sprint_dates',
return_value=(mock_start, mock_end, mock_period)):
since, until, period = did.base.Date.period(['this', 'sprint'])
assert str(since) == "2015-09-21"
assert str(until) == "2015-10-05"
assert period == "Sprint 42"
Comment thread
evakhoni marked this conversation as resolved.


@pytest.mark.usefixtures("_mock_today")
def test_last_sprint_period() -> None:
""" Test 'last sprint' period """
mock_start = did.base.Date("2015-09-07")
mock_end = did.base.Date("2015-09-21")
mock_period = "Sprint 41"

with patch.object(
did.plugins.jira,
'get_sprint_dates',
return_value=(mock_start, mock_end, mock_period)):
since, until, period = did.base.Date.period(['last', 'sprint'])
assert str(since) == "2015-09-07"
assert str(until) == "2015-09-21"
assert period == "Sprint 41"


def test_sprint_keyword_accepted() -> None:
""" Test that 'sprint' keyword is accepted in Options.check() """
# Mock sys.argv to provide a minimal valid config path
with patch.object(sys, 'argv', ['did', 'sprint']):
# Create a minimal config to allow Options init
did.base.Config(config="[general]\nemail = test@example.com\n")
options = did.cli.Options()
# Setting self.arg directly to test the check() method
options.arg = ['sprint']
# This should not raise OptionError
options.check()
Loading