diff --git a/analytics/Makefile b/analytics/Makefile index 040713929..c2b8d58bd 100644 --- a/analytics/Makefile +++ b/analytics/Makefile @@ -190,8 +190,7 @@ sprint-burndown: @echo "=> Running sprint burndown report" @echo "=====================================================" $(POETRY) analytics calculate sprint_burndown \ - --sprint-file $(SPRINT_FILE) \ - --issue-file $(ISSUE_FILE) \ + --issue-file $(DELIVERY_FILE) \ --output-dir $(OUTPUT_DIR) \ --sprint "$(SPRINT)" \ --unit $(UNIT) \ diff --git a/analytics/src/analytics/cli.py b/analytics/src/analytics/cli.py index e019a7e88..8002ac700 100644 --- a/analytics/src/analytics/cli.py +++ b/analytics/src/analytics/cli.py @@ -11,7 +11,6 @@ from analytics.datasets.deliverable_tasks import DeliverableTasks from analytics.datasets.issues import GitHubIssues -from analytics.datasets.sprint_board import SprintBoard from analytics.integrations import db, github, slack from analytics.metrics.base import BaseMetric, Unit from analytics.metrics.burndown import SprintBurndown @@ -133,7 +132,6 @@ def export_github_data( @metrics_app.command(name="sprint_burndown") def calculate_sprint_burndown( - sprint_file: Annotated[str, SPRINT_FILE_ARG], issue_file: Annotated[str, ISSUE_FILE_ARG], sprint: Annotated[str, SPRINT_ARG], unit: Annotated[Unit, UNIT_ARG] = Unit.points.value, # type: ignore[assignment] @@ -144,10 +142,7 @@ def calculate_sprint_burndown( ) -> None: """Calculate the burndown for a particular sprint.""" # load the input data - sprint_data = SprintBoard.load_from_json_files( - sprint_file=sprint_file, - issue_file=issue_file, - ) + sprint_data = GitHubIssues.from_json(issue_file) # calculate burndown burndown = SprintBurndown(sprint_data, sprint=sprint, unit=unit) show_and_or_post_results( @@ -160,7 +155,6 @@ def calculate_sprint_burndown( @metrics_app.command(name="sprint_burnup") def calculate_sprint_burnup( - sprint_file: Annotated[str, SPRINT_FILE_ARG], issue_file: Annotated[str, ISSUE_FILE_ARG], sprint: Annotated[str, SPRINT_ARG], unit: Annotated[Unit, UNIT_ARG] = Unit.points.value, # type: ignore[assignment] @@ -171,10 +165,7 @@ def calculate_sprint_burnup( ) -> None: """Calculate the burnup of a particular sprint.""" # load the input data - sprint_data = SprintBoard.load_from_json_files( - sprint_file=sprint_file, - issue_file=issue_file, - ) + sprint_data = GitHubIssues.from_json(issue_file) # calculate burnup burnup = SprintBurnup(sprint_data, sprint=sprint, unit=unit) show_and_or_post_results( diff --git a/analytics/src/analytics/datasets/issues.py b/analytics/src/analytics/datasets/issues.py index b726fed58..1d900eaff 100644 --- a/analytics/src/analytics/datasets/issues.py +++ b/analytics/src/analytics/datasets/issues.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Self -from pandas import DataFrame +import pandas as pd from pydantic import BaseModel, Field, ValidationError from analytics.datasets.base import BaseDataset @@ -69,15 +69,70 @@ class IssueMetadata(BaseModel): class GitHubIssues(BaseDataset): """GitHub issues with metadata about their parents (Epics and Deliverables) and sprints.""" - def __init__(self, df: DataFrame) -> None: + def __init__(self, df: pd.DataFrame) -> None: """Initialize the GitHub Issues dataset.""" - self.opened_col = "issue_created_at" + self.opened_col = "issue_opened_at" self.closed_col = "issue_closed_at" + self.points_col = "issue_points" self.sprint_col = "sprint_name" self.sprint_start_col = "sprint_start" self.sprint_end_col = "sprint_end" + self.date_cols = [ + self.sprint_start_col, + self.sprint_end_col, + self.opened_col, + self.closed_col, + ] + # Convert date cols into dates + for col in self.date_cols: + # strip off the timestamp portion of the date + df[col] = pd.to_datetime(df[col]).dt.floor("d") super().__init__(df) + def sprint_start(self, sprint: str) -> pd.Timestamp: + """Return the date on which a given sprint started.""" + sprint_mask = self.df[self.sprint_col] == sprint + return self.df.loc[sprint_mask, self.sprint_start_col].min() + + def sprint_end(self, sprint: str) -> pd.Timestamp: + """Return the date on which a given sprint ended.""" + sprint_mask = self.df[self.sprint_col] == sprint + return self.df.loc[sprint_mask, self.sprint_end_col].max() + + @property + def sprints(self) -> pd.DataFrame: + """Return the unique list of sprints with their start and end dates.""" + sprint_cols = [self.sprint_col, self.sprint_start_col, self.sprint_end_col] + return self.df[sprint_cols].drop_duplicates() + + @property + def current_sprint(self) -> str | None: + """Return the name of the current sprint, if a sprint is currently active.""" + return self.get_sprint_name_from_date(pd.Timestamp.today().floor("d")) + + def get_sprint_name_from_date(self, date: pd.Timestamp) -> str | None: + """Get the name of a sprint from a given date, if that date falls in a sprint.""" + # fmt: off + date_filter = ( + (self.sprints[self.sprint_start_col] < date) # after sprint start + & (self.sprints[self.sprint_end_col] >= date) # before sprint end + ) + # fmt: on + matching_sprints = self.sprints.loc[date_filter, self.sprint_col] + # if there aren't any sprints return None + if len(matching_sprints) == 0: + return None + # if there are, return the first value as a string + return str(matching_sprints.squeeze()) + + def to_dict(self) -> list[dict]: + """Convert this dataset to a python dictionary.""" + # Convert date cols into dates + for col in self.date_cols: + # strip off the timestamp portion of the date + self.df[col] = self.df[col].dt.strftime("%Y-%m-%d") + return super().to_dict() + @classmethod def load_from_json_files( cls, @@ -94,7 +149,7 @@ def load_from_json_files( lookup = populate_issue_lookup_table(lookup, sprint_data_in) # Flatten and write issue level data to output file issues = flatten_issue_data(lookup) - return cls(DataFrame(data=issues)) + return cls(pd.DataFrame(data=issues)) # =============================================================== diff --git a/analytics/src/analytics/metrics/burndown.py b/analytics/src/analytics/metrics/burndown.py index 6b5520600..6cee8f367 100644 --- a/analytics/src/analytics/metrics/burndown.py +++ b/analytics/src/analytics/metrics/burndown.py @@ -7,25 +7,25 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING import pandas as pd import plotly.express as px -from numpy import nan -from analytics.datasets.sprint_board import SprintBoard +from analytics.datasets.issues import GitHubIssues from analytics.metrics.base import BaseMetric, Statistic, Unit +from analytics.metrics.utils import Columns, sum_tix_by_day if TYPE_CHECKING: from plotly.graph_objects import Figure -class SprintBurndown(BaseMetric[SprintBoard]): +class SprintBurndown(BaseMetric[GitHubIssues]): """Calculates the running total of open issues per day in the sprint.""" def __init__( self, - dataset: SprintBoard, + dataset: GitHubIssues, sprint: str, unit: Unit, ) -> None: @@ -34,36 +34,34 @@ def __init__( self.sprint = self._get_and_validate_sprint_name(sprint) self.sprint_data = self._isolate_data_for_this_sprint() self.date_col = "date" - self.points_col = "points" - self.opened_col = dataset.opened_col # type: ignore[attr-defined] - self.closed_col = dataset.closed_col # type: ignore[attr-defined] + self.columns = Columns( + opened_at_col=dataset.opened_col, + closed_at_col=dataset.closed_col, + unit_col=dataset.points_col if unit == Unit.points else unit.value, + date_col=self.date_col, + ) self.unit = unit + # Set the value of the unit column based on + # whether we're summing issues or story points + self.unit_col = dataset.points_col if unit == Unit.points else unit.value super().__init__(dataset) def calculate(self) -> pd.DataFrame: - """ - Calculate the sprint burndown. - - Notes - ----- - Sprint burndown is calculated with the following algorithm: - 1. Isolate the records that belong to the given sprint - 2. Get the range of dates over which these issues were opened and closed - 3. Count the number of issues opened and closed on each day of that range - 4. Calculate the delta between opened and closed issues per day - 5. Cumulatively sum those deltas to get the running total of open tix - - """ + """Calculate the sprint burnup.""" # make a copy of columns and rows we need to calculate burndown for this sprint - burndown_cols = [self.opened_col, self.closed_col, self.points_col] - df_sprint = self.sprint_data[burndown_cols].copy() - # get the date range over which tix were created and closed - df_tix_range = self._get_tix_date_range(df_sprint) - # get the number of tix opened and closed each day - df_opened = self._get_daily_tix_counts_by_status(df_sprint, "opened") - df_closed = self._get_daily_tix_counts_by_status(df_sprint, "closed") - # combine the daily opened and closed counts to get total open per day - return self._get_cum_sum_of_open_tix(df_tix_range, df_opened, df_closed) + burnup_cols = [ + self.dataset.opened_col, + self.dataset.closed_col, + self.dataset.points_col, + ] + df_sprint = self.sprint_data[burnup_cols].copy() + # Count the number of tickets opened, closed, and remaining by day + return sum_tix_by_day( + df=df_sprint, + cols=self.columns, + unit=self.unit, + sprint_end=self.dataset.sprint_end(self.sprint), + ) def plot_results(self) -> Figure: """Plot the sprint burndown using a plotly line chart.""" @@ -74,7 +72,7 @@ def plot_results(self) -> Figure: sprint_end = self.dataset.sprint_end(self.sprint) date_mask = self.results[self.date_col].between( sprint_start, - min(sprint_end, pd.Timestamp.today(tz="utc")), + min(sprint_end, pd.Timestamp.today()), ) df = self.results[date_mask] # create a line chart from the data in self.results @@ -108,7 +106,7 @@ def get_stats(self) -> dict[str, Statistic]: total_closed = int(df["closed"].sum()) pct_closed = round(total_closed / total_opened * 100, 2) # get the percentage of tickets that were ticketed - is_pointed = self.sprint_data[Unit.points.value] >= 1 + is_pointed = self.sprint_data[self.dataset.points_col] >= 1 issues_pointed = len(self.sprint_data[is_pointed]) issues_total = len(self.sprint_data) pct_pointed = round(issues_pointed / issues_total * 100, 2) @@ -153,86 +151,3 @@ def _isolate_data_for_this_sprint(self) -> pd.DataFrame: """Filter out issues that are not assigned to the current sprint.""" sprint_filter = self.dataset.df[self.dataset.sprint_col] == self.sprint return self.dataset.df[sprint_filter] - - def _get_daily_tix_counts_by_status( - self, - df: pd.DataFrame, - status: Literal["opened", "closed"], - ) -> pd.DataFrame: - """ - Count the number of issues or points opened or closed by date. - - Notes - ----- - It does this by: - - Grouping on the created_date or opened_date column, depending on status - - Counting the total number of rows per group - - """ - # create local copies of the key column names - agg_col = self.opened_col if status == "opened" else self.closed_col - unit_col = self.unit.value - key_cols = [agg_col, unit_col] - # create a dummy column to sum per row if the unit is tasks - if self.unit == Unit.issues: - df[unit_col] = 1 - # isolate the key columns, group by open or closed date, then sum the units - df_agg = df[key_cols].groupby(agg_col, as_index=False).agg({unit_col: "sum"}) - return df_agg.rename(columns={agg_col: self.date_col, unit_col: status}) - - def _get_tix_date_range(self, df: pd.DataFrame) -> pd.DataFrame: - """ - Get the date range over which issues were created and closed. - - Notes - ----- - It does this by: - - Finding the date when the sprint ends - - Finding the earliest date a issue was created - - Finding the latest date a issue was closed - - Creating a row for each day between the earliest date a ticket was opened - and either the sprint end _or_ the latest date an issue was closed, - whichever is the later date. - - """ - # get earliest date an issue was opened and latest date one was closed - sprint_end = self.dataset.sprint_end(self.sprint) - opened_min = df[self.opened_col].min() - closed_max = df[self.closed_col].max() - closed_max = sprint_end if closed_max is nan else max(sprint_end, closed_max) - # creates a dataframe with one row for each day between min and max date - return pd.DataFrame( - pd.date_range(opened_min, closed_max), - columns=[self.date_col], - ) - - def _get_cum_sum_of_open_tix( - self, - dates: pd.DataFrame, - opened: pd.DataFrame, - closed: pd.DataFrame, - ) -> pd.DataFrame: - """ - Get the cumulative sum of open issues per day. - - Notes - ----- - It does this by: - - Left joining the full date range to the daily open and closed counts - so that we have a row for each day of the range, with a column for tix - opened and a column for tix closed on that day - - Subtracting closed from opened to get the "delta" on each day in the range - - Cumulatively summing the deltas to get the running total of open tix - - """ - # left join the full date range to open and closed counts - df = ( - dates.merge(opened, on=self.date_col, how="left") - .merge(closed, on=self.date_col, how="left") - .fillna(0) - ) - # calculate the difference between opened and closed each day - df["delta"] = df["opened"] - df["closed"] - # cumulatively sum the deltas to get the running total - df["total_open"] = df["delta"].cumsum() - return df diff --git a/analytics/src/analytics/metrics/burnup.py b/analytics/src/analytics/metrics/burnup.py index 098848c61..b93b78dda 100644 --- a/analytics/src/analytics/metrics/burnup.py +++ b/analytics/src/analytics/metrics/burnup.py @@ -12,24 +12,20 @@ import pandas as pd import plotly.express as px -from analytics.datasets.sprint_board import SprintBoard +from analytics.datasets.issues import GitHubIssues from analytics.metrics.base import BaseMetric, Statistic, Unit -from analytics.metrics.utils import ( - get_cum_sum_of_tix, - get_daily_tix_counts_by_status, - get_tix_date_range, -) +from analytics.metrics.utils import Columns, sum_tix_by_day if TYPE_CHECKING: from plotly.graph_objects import Figure -class SprintBurnup(BaseMetric[SprintBoard]): +class SprintBurnup(BaseMetric[GitHubIssues]): """Calculates the running total of open issues per day in the sprint.""" def __init__( self, - dataset: SprintBoard, + dataset: GitHubIssues, sprint: str, unit: Unit, ) -> None: @@ -38,40 +34,31 @@ def __init__( self.sprint = self._get_and_validate_sprint_name(sprint) self.sprint_data = self._isolate_data_for_this_sprint() self.date_col = "date" - self.points_col = "points" - self.opened_col = dataset.opened_col # type: ignore[attr-defined] - self.closed_col = dataset.closed_col # type: ignore[attr-defined] + self.columns = Columns( + opened_at_col=dataset.opened_col, + closed_at_col=dataset.closed_col, + unit_col=dataset.points_col if unit == Unit.points else unit.value, + date_col=self.date_col, + ) self.unit = unit super().__init__(dataset) def calculate(self) -> pd.DataFrame: - """ - Calculate the sprint burnup. - - Notes - ----- - Sprint burnup is calculated with the following algorithm: - 1. Isolate Sprint records - 2. Create data range for burnup - 3. Group issues/points by date opened and date closed - 4. Join on date - - """ + """Calculate the sprint burnup.""" # make a copy of columns and rows we need to calculate burndown for this sprint - burnup_cols = [self.opened_col, self.closed_col, self.points_col] + burnup_cols = [ + self.dataset.opened_col, + self.dataset.closed_col, + self.dataset.points_col, + ] df_sprint = self.sprint_data[burnup_cols].copy() - # get the date range over which tix were created and closed - df_tix_range = get_tix_date_range( - df_sprint, - self.opened_col, - self.closed_col, - self.dataset.sprint_end(self.sprint), + # Count the number of tickets opened, closed, and remaining by day + return sum_tix_by_day( + df=df_sprint, + cols=self.columns, + unit=self.unit, + sprint_end=self.dataset.sprint_end(self.sprint), ) - # get the number of tix opened and closed each day - df_opened = get_daily_tix_counts_by_status(df_sprint, "opened", self.unit) - df_closed = get_daily_tix_counts_by_status(df_sprint, "closed", self.unit) - # combine the daily opened and closed counts to get total open and closed per day - return get_cum_sum_of_tix(df_tix_range, df_opened, df_closed) def plot_results(self) -> Figure: """Plot the sprint burnup using a plotly area chart.""" @@ -82,7 +69,7 @@ def plot_results(self) -> Figure: sprint_end = self.dataset.sprint_end(self.sprint) date_mask = self.results[self.date_col].between( sprint_start, - min(sprint_end, pd.Timestamp.today(tz="utc")), + min(sprint_end, pd.Timestamp.today()), ) df = self.results[date_mask].melt( id_vars=self.date_col, @@ -124,7 +111,7 @@ def get_stats(self) -> dict[str, Statistic]: # For burnup, we want to know at a glance the pct_remaining pct_remaining = round(100 - pct_closed, 2) # get the percentage of tickets that were ticketed - is_pointed = self.sprint_data[Unit.points.value] >= 1 + is_pointed = self.sprint_data[self.dataset.points_col] >= 1 issues_pointed = len(self.sprint_data[is_pointed]) issues_total = len(self.sprint_data) pct_pointed = round(issues_pointed / issues_total * 100, 2) @@ -168,42 +155,3 @@ def _isolate_data_for_this_sprint(self) -> pd.DataFrame: """Filter out issues that are not assigned to the current sprint.""" sprint_filter = self.dataset.df[self.dataset.sprint_col] == self.sprint return self.dataset.df[sprint_filter] - - # def _get_daily_tix_counts_by_status( - # self, - # df: pd.DataFrame, - # status: Literal["opened", "closed"], - # ) -> pd.DataFrame: - # """ - # Count the number of issues or points opened or closed by date. - - # Notes - # ----- - # It does this by: - # - Grouping on the created_date or opened_date column, depending on status - # - Counting the total number of rows per group - - # """ - # # create local copies of the key column names - # # create a dummy column to sum per row if the unit is tasks - # if self.unit == Unit.issues: - # # isolate the key columns, group by open or closed date, then sum the units - - # def _get_tix_date_range(self, df: pd.DataFrame) -> pd.DataFrame: - # """ - # Get the date range over which issues were created and closed. - - # Notes - # ----- - # It does this by: - # - Finding the date when the sprint ends - # - Finding the earliest date a issue was created - # - Finding the latest date a issue was closed - # - Creating a row for each day between the earliest date a ticket was opened - # and either the sprint end _or_ the latest date an issue was closed, - # whichever is the later date. - - # """ - # # get earliest date an issue was opened and latest date one was closed - # # creates a dataframe with one row for each day between min and max date - # return pd.DataFrame( diff --git a/analytics/src/analytics/metrics/utils.py b/analytics/src/analytics/metrics/utils.py index f9af3b128..4db39b850 100644 --- a/analytics/src/analytics/metrics/utils.py +++ b/analytics/src/analytics/metrics/utils.py @@ -2,16 +2,54 @@ from __future__ import annotations -from typing import Literal +from dataclasses import dataclass +from enum import StrEnum import pandas as pd from analytics.metrics.base import Unit +@dataclass +class Columns: + """List of columns names to use when calculating burnup/down.""" + + opened_at_col: str + closed_at_col: str + unit_col: str + date_col: str = "date" + opened_count_col: str = "opened" + closed_count_col: str = "closed" + delta_col: str = "delta" + + +class IssueState(StrEnum): + """Whether the issue is open or closed.""" + + OPEN = "opened" + CLOSED = "closed" + + +def sum_tix_by_day( + df: pd.DataFrame, + cols: Columns, + unit: Unit, + sprint_end: pd.Timestamp, +) -> pd.DataFrame: + """Count the total number of tix opened, closed, and remaining by day.""" + # Get the date range for burndown/burnup + df_tix_range = get_tix_date_range(df, cols, sprint_end) + # Get the number of tix opened and closed by day + df_opened = get_daily_tix_counts_by_status(df, cols, IssueState.OPEN, unit) + df_closed = get_daily_tix_counts_by_status(df, cols, IssueState.CLOSED, unit) + # combine the daily opened and closed counts to get total open and closed per day + return get_cum_sum_of_tix(cols, df_tix_range, df_opened, df_closed) + + def get_daily_tix_counts_by_status( df: pd.DataFrame, - status: Literal["opened", "closed"], + cols: Columns, + state: IssueState, unit: Unit, ) -> pd.DataFrame: """ @@ -20,23 +58,22 @@ def get_daily_tix_counts_by_status( Notes ----- It does this by: - - Grouping on the created_date or opened_date column, depending on status + - Grouping on the created_date or opened_date column, depending on state - Counting the total number of rows per group """ - agg_col = "created_date" if status == "opened" else "closed_date" - unit_col = unit.value + agg_col = cols.opened_at_col if state == IssueState.OPEN else cols.closed_at_col + unit_col = cols.unit_col key_cols = [agg_col, unit_col] if unit == Unit.issues: df[unit_col] = 1 df_agg = df[key_cols].groupby(agg_col, as_index=False).agg({unit_col: "sum"}) - return df_agg.rename(columns={agg_col: "date", unit_col: status}) + return df_agg.rename(columns={agg_col: "date", unit_col: state.value}) def get_tix_date_range( df: pd.DataFrame, - open_col: str | None, - closed_col: str | None, + cols: Columns, sprint_end: pd.Timestamp, ) -> pd.DataFrame: """ @@ -53,8 +90,8 @@ def get_tix_date_range( whichever is the later date. """ - opened_min = df[open_col].min() - closed_max = df[closed_col].max() + opened_min = df[cols.opened_at_col].min() + closed_max = df[cols.closed_at_col].max() closed_max = sprint_end if pd.isna(closed_max) else max(sprint_end, closed_max) return pd.DataFrame( pd.date_range(opened_min, closed_max), @@ -63,10 +100,10 @@ def get_tix_date_range( def get_cum_sum_of_tix( + cols: Columns, dates: pd.DataFrame, opened: pd.DataFrame, closed: pd.DataFrame, - date_col: str | None = None, ) -> pd.DataFrame: """ Create results data frame. @@ -82,11 +119,11 @@ def get_cum_sum_of_tix( """ df = ( - dates.merge(opened, on=date_col, how="left") - .merge(closed, on=date_col, how="left") + dates.merge(opened, on=cols.date_col, how="left") + .merge(closed, on=cols.date_col, how="left") .fillna(0) ) - df["delta"] = df["opened"] - df["closed"] - df["total_open"] = df["delta"].cumsum() - df["total_closed"] = df["closed"].cumsum() + df[cols.delta_col] = df[cols.opened_count_col] - df[cols.closed_count_col] + df["total_open"] = df[cols.delta_col].cumsum() + df["total_closed"] = df[cols.closed_count_col].cumsum() return df diff --git a/analytics/tests/conftest.py b/analytics/tests/conftest.py index 4e8192e89..18952ac36 100644 --- a/analytics/tests/conftest.py +++ b/analytics/tests/conftest.py @@ -1,3 +1,4 @@ +# pylint: disable=redefined-outer-name """ Configure pytest settings and create reusable fixtures and functions. @@ -10,6 +11,7 @@ import pandas as pd import pytest +from analytics.datasets.issues import IssueMetadata, IssueType # skips the integration tests in tests/integrations/ # to run the integration tests, invoke them directly: pytest tests/integrations/ @@ -58,7 +60,7 @@ def mock_slackbot_fixture(): return MockSlackbot() -def write_test_data_to_file(data: dict | list[dict], output_file: str): +def write_test_data_to_file(data: dict | list[dict], output_file: str | Path): """Write test JSON data to a file for use in a test.""" parent_dir = Path(output_file).parent parent_dir.mkdir(exist_ok=True, parents=True) @@ -167,7 +169,7 @@ def sprint_row( created: str = DAY_1, closed: str | None = None, status: str = "In Progress", - points: int = 1, + points: int | None = 1, sprint: int = 1, sprint_start: str = DAY_1, sprint_length: int = 2, @@ -201,3 +203,46 @@ def sprint_row( "created_date": created_date, "closed_date": closed_date, } + + +def issue( # pylint: disable=too-many-locals + issue: int, + kind: IssueType = IssueType.TASK, + parent: str | None = None, + points: int | None = 1, + quad: str | None = None, + epic: str | None = None, + deliverable: str | None = None, + sprint: int = 1, + sprint_start: str = DAY_0, + sprint_length: int = 2, + created: str = DAY_0, + closed: str | None = None, +) -> IssueMetadata: + """Create a new issue.""" + # Create issue name + name = f"{kind.value}{issue}" + # Create sprint timestamp fields + sprint_name = f"Sprint {sprint}" + sprint_start_ts = pd.Timestamp(sprint_start) + sprint_duration = pd.Timedelta(days=sprint_length) + sprint_end_ts = sprint_start_ts + sprint_duration + return IssueMetadata( + issue_title=name, + issue_type=kind.value, + issue_url=name, + issue_is_closed=bool(closed), + issue_opened_at=created, + issue_closed_at=closed, + issue_parent=parent, + issue_points=points, + quad_name=quad, + epic_title=epic, + epic_url=epic, + deliverable_title=deliverable, + deliverable_url=deliverable, + sprint_id=sprint_name, + sprint_name=sprint_name, + sprint_start=sprint_start, + sprint_end=sprint_end_ts.strftime("%Y-%m-%d"), + ) diff --git a/analytics/tests/datasets/test_issues.py b/analytics/tests/datasets/test_issues.py index 76d61641e..70c43df55 100644 --- a/analytics/tests/datasets/test_issues.py +++ b/analytics/tests/datasets/test_issues.py @@ -2,40 +2,24 @@ from pathlib import Path +import pandas as pd +import pytest from analytics.datasets.issues import ( GitHubIssues, - IssueMetadata, IssueType, get_parent_with_type, ) from analytics.datasets.utils import dump_to_json - -def issue( - name: str, - kind: IssueType = IssueType.TASK, - parent: str | None = None, - points: int | None = None, - quad: str | None = None, - epic: str | None = None, - deliverable: str | None = None, -) -> IssueMetadata: - """Create a new issue.""" - return IssueMetadata( - issue_title=name, - issue_type=kind.value, - issue_url=name, - issue_is_closed=False, - issue_opened_at="2024-02-01", - issue_closed_at=None, - issue_parent=parent, - issue_points=points, - quad_name=quad, - epic_title=epic, - epic_url=epic, - deliverable_title=deliverable, - deliverable_url=deliverable, - ) +from tests.conftest import ( + DAY_0, + DAY_1, + DAY_2, + DAY_3, + DAY_4, + DAY_5, + issue, +) class TestGitHubIssues: @@ -46,37 +30,37 @@ def test_load_from_json_files(self, tmp_path: Path): # Arrange - create dummy sprint data sprint_file = tmp_path / "sprint-data.json" sprint_data = [ - issue(name="task1", kind=IssueType.TASK, parent="epic1", points=2), - issue(name="task2", kind=IssueType.TASK, parent="epic2", points=1), + issue(issue=1, kind=IssueType.TASK, parent="Epic3", points=2), + issue(issue=2, kind=IssueType.TASK, parent="Epic4", points=1), ] roadmap_data = [i.model_dump() for i in sprint_data] dump_to_json(str(sprint_file), roadmap_data) # Act - create dummy roadmap data roadmap_file = tmp_path / "roadmap-data.json" roadmap_data = [ - issue(name="epic1", kind=IssueType.EPIC, parent="del1"), - issue(name="epic2", kind=IssueType.EPIC, parent="del2"), - issue(name="del1", kind=IssueType.DELIVERABLE, quad="quad1"), + issue(issue=3, kind=IssueType.EPIC, parent="Deliverable5"), + issue(issue=4, kind=IssueType.EPIC, parent="Deliverable6"), + issue(issue=5, kind=IssueType.DELIVERABLE, quad="quad1"), ] roadmap_data = [i.model_dump() for i in roadmap_data] dump_to_json(str(roadmap_file), roadmap_data) # Arrange output_data = [ issue( - name="task1", + issue=1, points=2, - parent="epic1", - deliverable="del1", + parent="Epic3", + deliverable="Deliverable5", quad="quad1", - epic="epic1", + epic="Epic3", ), issue( - name="task2", + issue=2, points=1, - parent="epic2", + parent="Epic4", deliverable=None, quad=None, - epic="epic2", + epic="Epic4", ), ] wanted = [i.model_dump() for i in output_data] @@ -95,12 +79,13 @@ class TestGetParentWithType: def test_return_epic_that_is_direct_parent_of_issue(self): """Return the correct epic for an issue that is one level down.""" # Arrange - task = "task" + task = "Task1" + epic = "Epic1" lookup = { - task: issue(name=task, kind=IssueType.TASK, parent="epic"), - "epic": issue(name=task, kind=IssueType.EPIC, parent=None), + task: issue(issue=1, kind=IssueType.TASK, parent=epic), + epic: issue(issue=2, kind=IssueType.EPIC, parent=None), } - wanted = lookup["epic"] + wanted = lookup[epic] # Act got = get_parent_with_type( child_url=task, @@ -113,13 +98,15 @@ def test_return_epic_that_is_direct_parent_of_issue(self): def test_return_correct_deliverable_that_is_grandparent_of_issue(self): """Return the correct deliverable for an issue that is two levels down.""" # Arrange - task = "task" + task = "Task1" + epic = "Epic2" + deliverable = "Deliverable3" lookup = { - task: issue(name=task, kind=IssueType.TASK, parent="epic"), - "epic": issue(name=task, kind=IssueType.EPIC, parent="deliverable"), - "deliverable": issue(name=task, kind=IssueType.DELIVERABLE, parent=None), + task: issue(issue=1, kind=IssueType.TASK, parent=epic), + epic: issue(issue=2, kind=IssueType.EPIC, parent=deliverable), + deliverable: issue(issue=3, kind=IssueType.DELIVERABLE, parent=None), } - wanted = lookup["deliverable"] + wanted = lookup[deliverable] # Act got = get_parent_with_type( child_url=task, @@ -134,7 +121,7 @@ def test_return_none_if_issue_has_no_parent(self): # Arrange task = "task" lookup = { - task: issue(name=task, kind=IssueType.TASK, parent=None), + task: issue(issue=1, kind=IssueType.TASK, parent=None), } wanted = None # Act @@ -149,10 +136,11 @@ def test_return_none_if_issue_has_no_parent(self): def test_return_none_if_parents_form_a_cycle(self): """Return None if the issue hierarchy forms a cycle.""" # Arrange - task = "task" + task = "Task1" + parent = "Task2" lookup = { - task: issue(name=task, kind=IssueType.TASK, parent="parent"), - "parent": issue(name=task, kind=IssueType.TASK, parent=task), + task: issue(issue=1, kind=IssueType.TASK, parent="parent"), + parent: issue(issue=2, kind=IssueType.TASK, parent=task), } wanted = None # Act @@ -167,11 +155,13 @@ def test_return_none_if_parents_form_a_cycle(self): def test_return_none_if_deliverable_is_not_found_in_parents(self): """Return None if the desired type (e.g. epic) isn't found in the list of parents.""" # Arrange - task = "task" + task = "Task1" + parent = "Task2" + epic = "Epic3" lookup = { - task: issue(name=task, kind=IssueType.TASK, parent="parent"), - "parent": issue(name=task, kind=IssueType.TASK, parent="epic"), - "epic": issue(name=task, kind=IssueType.EPIC, parent=task), + task: issue(issue=1, kind=IssueType.TASK, parent=parent), + parent: issue(issue=2, kind=IssueType.TASK, parent=epic), + epic: issue(issue=3, kind=IssueType.EPIC, parent=task), } wanted = None # Act @@ -182,3 +172,64 @@ def test_return_none_if_deliverable_is_not_found_in_parents(self): ) # Assert assert got == wanted + + +class TestGetSprintNameFromDate: + """Test the GitHubIssues.get_sprint_name_from_date() method.""" + + @pytest.mark.parametrize( + ("date", "expected"), + [ + (DAY_1, "Sprint 1"), + (DAY_2, "Sprint 1"), + (DAY_4, "Sprint 2"), + (DAY_5, "Sprint 2"), + ], + ) + def test_return_name_if_matching_sprint_exists(self, date: str, expected: str): + """Test that correct sprint is returned if date exists in a sprint.""" + # setup - create sample dataset + board_data = [ + issue(issue=1, sprint=1, sprint_start=DAY_0, sprint_length=3), + issue(issue=2, sprint=1, sprint_start=DAY_0, sprint_length=3), + issue(issue=3, sprint=2, sprint_start=DAY_3, sprint_length=3), + ] + board_data = [i.__dict__ for i in board_data] + board = GitHubIssues.from_dict(board_data) + # validation + sprint_date = pd.Timestamp(date) + sprint_name = board.get_sprint_name_from_date(sprint_date) + assert sprint_name == expected + + def test_return_none_if_no_matching_sprint(self): + """The method should return None if no sprint contains the date.""" + # setup - create sample dataset + board_data = [ + issue(issue=1, sprint=1, sprint_start=DAY_1), + issue(issue=2, sprint=2, sprint_start=DAY_4), + ] + board_data = [i.__dict__ for i in board_data] + board = GitHubIssues.from_dict(board_data) + # validation + bad_date = pd.Timestamp("1900-01-01") + sprint_name = board.get_sprint_name_from_date(bad_date) + assert sprint_name is None + + def test_return_previous_sprint_if_date_is_start_of_next_sprint(self): + """ + Test correct behavior for sprint end/start dates. + + If date provided is both the the end of one sprint and the beginning of + another, then return the name of the sprint that just ended. + """ + # setup - create sample dataset + board_data = [ + issue(issue=1, sprint=1, sprint_start=DAY_1, sprint_length=2), + issue(issue=2, sprint=2, sprint_start=DAY_3, sprint_length=2), + ] + board_data = [i.__dict__ for i in board_data] + board = GitHubIssues.from_dict(board_data) + # execution + bad_date = pd.Timestamp(DAY_3) # end of sprint 1 and start of sprint 2 + sprint_name = board.get_sprint_name_from_date(bad_date) + assert sprint_name == "Sprint 1" diff --git a/analytics/tests/metrics/test_burndown.py b/analytics/tests/metrics/test_burndown.py index 9fe043c86..3649be5d2 100644 --- a/analytics/tests/metrics/test_burndown.py +++ b/analytics/tests/metrics/test_burndown.py @@ -5,7 +5,7 @@ import pandas as pd import pytest -from analytics.datasets.sprint_board import SprintBoard +from analytics.datasets.issues import GitHubIssues from analytics.metrics.burndown import SprintBurndown, Unit from tests.conftest import ( @@ -15,7 +15,7 @@ DAY_3, DAY_4, MockSlackbot, - sprint_row, + issue, ) @@ -25,14 +25,16 @@ def result_row( closed: int, delta: int, total: int, + closed_total: int, ) -> dict: """Create a sample result row.""" return { - "date": pd.Timestamp(day, tz="UTC"), + "date": pd.Timestamp(day), "opened": opened, "closed": closed, "delta": delta, "total_open": total, + "total_closed": closed_total, } @@ -41,10 +43,11 @@ def sample_burndown_by_points_fixture() -> SprintBurndown: """Create a sample burndown to simplify test setup.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # return sprint burndown by points return SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) @@ -58,61 +61,67 @@ def test_exclude_tix_assigned_to_other_sprints(self): sprint_data = [ # fmt: off # include this row - assigned to sprint 1 - sprint_row(issue=1, sprint=1, sprint_start=DAY_1, created=DAY_1, closed=DAY_3), + issue(issue=1, sprint=1, sprint_start=DAY_1, created=DAY_1, closed=DAY_3), # exclude this row - assigned to sprint 2 - sprint_row(issue=1, sprint=2, sprint_start=DAY_4, created=DAY_0, closed=DAY_4), + issue(issue=1, sprint=2, sprint_start=DAY_4, created=DAY_0, closed=DAY_4), # fmt: on ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check min and max dates - assert df[output.date_col].min() == pd.Timestamp(DAY_1, tz="UTC") - assert df[output.date_col].max() == pd.Timestamp(DAY_3, tz="UTC") + assert df[output.date_col].min() == pd.Timestamp(DAY_1) + assert df[output.date_col].max() == pd.Timestamp(DAY_3) # validation - check burndown output + # fmt: off expected = [ - result_row(day=DAY_1, opened=1, closed=0, delta=1, total=1), - result_row(day=DAY_2, opened=0, closed=0, delta=0, total=1), - result_row(day=DAY_3, opened=0, closed=1, delta=-1, total=0), + result_row(day=DAY_1, opened=1, closed=0, delta=1, total=1, closed_total=0), + result_row(day=DAY_2, opened=0, closed=0, delta=0, total=1, closed_total=0), + result_row(day=DAY_3, opened=0, closed=1, delta=-1, total=0, closed_total=1), ] + # fmt: on assert df.to_dict("records") == expected def test_count_tix_created_before_sprint_start(self): """Burndown should include tix opened before the sprint but closed during it.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check min and max dates - assert df[output.date_col].min() == pd.Timestamp(DAY_0, tz="UTC") - assert df[output.date_col].max() == pd.Timestamp(DAY_3, tz="UTC") + assert df[output.date_col].min() == pd.Timestamp(DAY_0) + assert df[output.date_col].max() == pd.Timestamp(DAY_3) # validation - check burndown output + # fmt: off expected = [ - result_row(day=DAY_0, opened=2, closed=0, delta=2, total=2), - result_row(day=DAY_1, opened=0, closed=0, delta=0, total=2), - result_row(day=DAY_2, opened=0, closed=1, delta=-1, total=1), - result_row(day=DAY_3, opened=0, closed=1, delta=-1, total=0), + result_row(day=DAY_0, opened=2, closed=0, delta=2, total=2, closed_total=0), + result_row(day=DAY_1, opened=0, closed=0, delta=0, total=2, closed_total=0), + result_row(day=DAY_2, opened=0, closed=1, delta=-1, total=1, closed_total=1), + result_row(day=DAY_3, opened=0, closed=1, delta=-1, total=0, closed_total=2), ] + # fmt: on assert df.to_dict("records") == expected def test_count_tix_closed_after_sprint_start(self): """Burndown should include tix closed after the sprint ended.""" # setup - create test data sprint_data = [ - sprint_row( # closed before sprint end + issue( # closed before sprint end issue=1, sprint_start=DAY_1, sprint_length=2, created=DAY_1, closed=DAY_2, ), - sprint_row( # closed after sprint end + issue( # closed after sprint end issue=1, sprint_start=DAY_1, sprint_length=2, @@ -120,64 +129,72 @@ def test_count_tix_closed_after_sprint_start(self): closed=DAY_4, ), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check min and max dates - assert df[output.date_col].min() == pd.Timestamp(DAY_1, tz="UTC") - assert df[output.date_col].max() == pd.Timestamp(DAY_4, tz="UTC") + assert df[output.date_col].min() == pd.Timestamp(DAY_1) + assert df[output.date_col].max() == pd.Timestamp(DAY_4) # validation - check burndown output + # fmt: off expected = [ - result_row(day=DAY_1, opened=2, closed=0, delta=2, total=2), - result_row(day=DAY_2, opened=0, closed=1, delta=-1, total=1), - result_row(day=DAY_3, opened=0, closed=0, delta=0, total=1), - result_row(day=DAY_4, opened=0, closed=1, delta=-1, total=0), + result_row(day=DAY_1, opened=2, closed=0, delta=2, total=2, closed_total=0), + result_row(day=DAY_2, opened=0, closed=1, delta=-1, total=1, closed_total=1), + result_row(day=DAY_3, opened=0, closed=0, delta=0, total=1, closed_total=1), + result_row(day=DAY_4, opened=0, closed=1, delta=-1, total=0, closed_total=2), ] + # fmt: on assert df.to_dict("records") == expected def test_count_tix_created_after_sprint_start(self): """Burndown should include tix opened and closed during the sprint.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, closed=DAY_3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, closed=DAY_3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check burndown output + # fmt: off expected = [ - result_row(day=DAY_0, opened=1, closed=0, delta=1, total=1), - result_row(day=DAY_1, opened=0, closed=0, delta=0, total=1), - result_row(day=DAY_2, opened=1, closed=1, delta=0, total=1), - result_row(day=DAY_3, opened=0, closed=1, delta=-1, total=0), + result_row(day=DAY_0, opened=1, closed=0, delta=1, total=1, closed_total=0), + result_row(day=DAY_1, opened=0, closed=0, delta=0, total=1, closed_total=0), + result_row(day=DAY_2, opened=1, closed=1, delta=0, total=1, closed_total=1), + result_row(day=DAY_3, opened=0, closed=1, delta=-1, total=0, closed_total=2), ] + # fmt: on assert df.to_dict("records") == expected def test_include_all_sprint_days_if_tix_closed_early(self): """All days of the sprint should be included even if all tix were closed early.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check max date is end of sprint not last closed date - assert df[output.date_col].max() == pd.Timestamp(DAY_3, tz="UTC") + assert df[output.date_col].max() == pd.Timestamp(DAY_3) def test_raise_value_error_if_sprint_arg_not_in_dataset(self): """A ValueError should be raised if the sprint argument isn't valid.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), + issue(issue=1, sprint_start=DAY_1, created=DAY_0), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # validation with pytest.raises( ValueError, @@ -193,19 +210,22 @@ def test_calculate_burndown_for_current_sprint(self): day_2 = today.strftime("%Y-%m-%d") day_3 = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d") sprint_data = [ # note sprint duration is 2 days by default - sprint_row(issue=1, sprint_start=day_1, created=day_1, closed=day_2), - sprint_row(issue=1, sprint_start=day_1, created=day_1), + issue(issue=1, sprint_start=day_1, created=day_1, closed=day_2), + issue(issue=1, sprint_start=day_1, created=day_1), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="@current", unit=Unit.issues) df = output.results # validation - check burndown output + # fmt: off expected = [ - result_row(day=day_1, opened=2, closed=0, delta=2, total=2), - result_row(day=day_2, opened=0, closed=1, delta=-1, total=1), - result_row(day=day_3, opened=0, closed=0, delta=0, total=1), + result_row(day=day_1, opened=2, closed=0, delta=2, total=2, closed_total=0), + result_row(day=day_2, opened=0, closed=1, delta=-1, total=1, closed_total=1), + result_row(day=day_3, opened=0, closed=0, delta=0, total=1, closed_total=1), ] + # fmt: on assert df.to_dict("records") == expected @@ -216,40 +236,46 @@ def test_burndown_works_with_points(self): """Burndown should be calculated correctly with points.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) df = output.results # validation + # fmt: off expected = [ - result_row(day=DAY_0, opened=2, closed=0, delta=2, total=2), - result_row(day=DAY_1, opened=0, closed=0, delta=0, total=2), - result_row(day=DAY_2, opened=3, closed=0, delta=3, total=5), - result_row(day=DAY_3, opened=0, closed=0, delta=0, total=5), + result_row(day=DAY_0, opened=2, closed=0, delta=2, total=2, closed_total=0), + result_row(day=DAY_1, opened=0, closed=0, delta=0, total=2, closed_total=0), + result_row(day=DAY_2, opened=3, closed=0, delta=3, total=5, closed_total=0), + result_row(day=DAY_3, opened=0, closed=0, delta=0, total=5, closed_total=0), ] + # fmt: on assert df.to_dict("records") == expected def test_burndown_excludes_tix_without_points(self): """Burndown should exclude tickets that are not pointed.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_1, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=0), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=None), + issue(issue=1, sprint_start=DAY_1, created=DAY_1, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=0), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=None), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) df = output.results # validation + # fmt: off expected = [ - result_row(day=DAY_1, opened=2, closed=0, delta=2, total=2), - result_row(day=DAY_2, opened=0, closed=0, delta=0, total=2), - result_row(day=DAY_3, opened=0, closed=0, delta=0, total=2), + result_row(day=DAY_1, opened=2, closed=0, delta=2, total=2, closed_total=0), + result_row(day=DAY_2, opened=0, closed=0, delta=0, total=2, closed_total=0), + result_row(day=DAY_3, opened=0, closed=0, delta=0, total=2, closed_total=0), ] + # fmt: on assert df.to_dict("records") == expected @@ -267,16 +293,17 @@ def test_sprint_start_and_sprint_end_not_affected_by_unit(self): """Test that sprint start and end are the same regardless of unit.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), - sprint_row(issue=2, sprint_start=DAY_1, created=DAY_2, closed=DAY_4), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), + issue(issue=2, sprint_start=DAY_1, created=DAY_2, closed=DAY_4), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution points = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) issues = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) # validation - check they're calculated correctly - assert points.stats.get(self.SPRINT_START).value == DAY_1 - assert points.stats.get(self.SPRINT_END).value == DAY_3 + assert points.stats[self.SPRINT_START].value == DAY_1 + assert points.stats[self.SPRINT_END].value == DAY_3 # validation - check that they are the same # fmt: off assert points.stats.get(self.SPRINT_START) == issues.stats.get(self.SPRINT_START) @@ -287,114 +314,119 @@ def test_get_total_closed_and_opened_when_unit_is_issues(self): """Test that total_closed is calculated correctly when unit is issues.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint=1, created=DAY_0, closed=DAY_2), - sprint_row(issue=2, sprint=1, created=DAY_0, closed=DAY_3), - sprint_row(issue=3, sprint=1, created=DAY_2), # not closed - sprint_row(issue=4, sprint=1, created=DAY_2), # not closed + issue(issue=1, sprint=1, created=DAY_0, closed=DAY_2), + issue(issue=2, sprint=1, created=DAY_0, closed=DAY_3), + issue(issue=3, sprint=1, created=DAY_2), # not closed + issue(issue=4, sprint=1, created=DAY_2), # not closed ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) print(output.results) # validation - check that stats were calculated correctly - assert output.stats.get(self.TOTAL_CLOSED).value == 2 - assert output.stats.get(self.TOTAL_OPENED).value == 4 - assert output.stats.get(self.PCT_CLOSED).value == 50.0 + assert output.stats[self.TOTAL_CLOSED].value == 2 + assert output.stats[self.TOTAL_OPENED].value == 4 + assert output.stats[self.PCT_CLOSED].value == 50.0 # validation - check that message contains string value of Unit.issues - assert Unit.issues.value in output.stats.get(self.TOTAL_CLOSED).suffix - assert Unit.issues.value in output.stats.get(self.TOTAL_OPENED).suffix - assert "%" in output.stats.get(self.PCT_CLOSED).suffix + assert Unit.issues.value in output.stats[self.TOTAL_CLOSED].suffix + assert Unit.issues.value in output.stats[self.TOTAL_OPENED].suffix + assert "%" in output.stats[self.PCT_CLOSED].suffix def test_get_total_closed_and_opened_when_unit_is_points(self): """Test that total_closed is calculated correctly when unit is issues.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), - sprint_row(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), - sprint_row(issue=3, sprint=1, created=DAY_2, points=2), # not closed - sprint_row(issue=4, sprint=1, created=DAY_2, points=4), # not closed + issue(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), + issue(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), + issue(issue=3, sprint=1, created=DAY_2, points=2), # not closed + issue(issue=4, sprint=1, created=DAY_2, points=4), # not closed ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) # validation - assert output.stats.get(self.TOTAL_CLOSED).value == 3 - assert output.stats.get(self.TOTAL_OPENED).value == 9 - assert output.stats.get(self.PCT_CLOSED).value == 33.33 # rounded to 2 places + assert output.stats[self.TOTAL_CLOSED].value == 3 + assert output.stats[self.TOTAL_OPENED].value == 9 + assert output.stats[self.PCT_CLOSED].value == 33.33 # rounded to 2 places # validation - check that message contains string value of Unit.points - assert Unit.points.value in output.stats.get(self.TOTAL_CLOSED).suffix - assert Unit.points.value in output.stats.get(self.TOTAL_OPENED).suffix - assert "%" in output.stats.get(self.PCT_CLOSED).suffix + assert Unit.points.value in output.stats[self.TOTAL_CLOSED].suffix + assert Unit.points.value in output.stats[self.TOTAL_OPENED].suffix + assert "%" in output.stats[self.PCT_CLOSED].suffix def test_include_issues_closed_after_sprint_end(self): """Issues that are closed after sprint ended should be included in closed count.""" # setup - create test data sprint_data = [ - sprint_row( # closed during sprint + issue( # closed during sprint issue=1, sprint_start=DAY_1, sprint_length=2, created=DAY_1, closed=DAY_2, ), - sprint_row( # closed after sprint + issue( # closed after sprint issue=2, sprint_start=DAY_1, sprint_length=2, created=DAY_2, closed=DAY_4, ), - sprint_row( # not closed + issue( # not closed issue=3, sprint_start=DAY_1, sprint_length=2, created=DAY_2, ), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) # validation - assert output.stats.get(self.TOTAL_CLOSED).value == 2 - assert output.stats.get(self.TOTAL_OPENED).value == 3 - assert output.stats.get(self.PCT_CLOSED).value == 66.67 # rounded to 2 places + assert output.stats[self.TOTAL_CLOSED].value == 2 + assert output.stats[self.TOTAL_OPENED].value == 3 + assert output.stats[self.PCT_CLOSED].value == 66.67 # rounded to 2 places def test_get_percent_pointed(self): """Test that percent pointed is calculated correctly.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), - sprint_row(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), - sprint_row(issue=3, sprint=1, created=DAY_2, points=None), # not pointed - sprint_row(issue=4, sprint=1, created=DAY_2, points=0), # not closed + issue(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), + issue(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), + issue(issue=3, sprint=1, created=DAY_2, points=None), # not pointed + issue(issue=4, sprint=1, created=DAY_2, points=0), # not closed ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) # validation - assert output.stats.get(self.TOTAL_CLOSED).value == 3 - assert output.stats.get(self.TOTAL_OPENED).value == 3 - assert output.stats.get(self.PCT_CLOSED).value == 100 - assert output.stats.get(self.PCT_POINTED).value == 50 + assert output.stats[self.TOTAL_CLOSED].value == 3 + assert output.stats[self.TOTAL_OPENED].value == 3 + assert output.stats[self.PCT_CLOSED].value == 100 + assert output.stats[self.PCT_POINTED].value == 50 # validation - check that stat contains '%' suffix - assert f"% of {Unit.issues.value}" in output.stats.get(self.PCT_POINTED).suffix + assert f"% of {Unit.issues.value}" in output.stats[self.PCT_POINTED].suffix def test_exclude_other_sprints_in_percent_pointed(self): """Only include issues in this sprint when calculating percent pointed.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), - sprint_row(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), - sprint_row(issue=3, sprint=1, created=DAY_2, points=None), # not pointed - sprint_row(issue=4, sprint=2, created=DAY_2, points=None), # other sprint + issue(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), + issue(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), + issue(issue=3, sprint=1, created=DAY_2, points=None), # not pointed + issue(issue=4, sprint=2, created=DAY_2, points=None), # other sprint ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) # validation - assert output.stats.get(self.TOTAL_CLOSED).value == 2 - assert output.stats.get(self.TOTAL_OPENED).value == 3 - assert output.stats.get(self.PCT_POINTED).value == 66.67 # exclude final row + assert output.stats[self.TOTAL_CLOSED].value == 2 + assert output.stats[self.TOTAL_OPENED].value == 3 + assert output.stats[self.PCT_POINTED].value == 66.67 # exclude final row class TestFormatSlackMessage: @@ -404,10 +436,11 @@ def test_slack_message_contains_right_number_of_lines(self): """Message should contain one line for the title and one for each stat.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) lines = output.format_slack_message().splitlines() @@ -420,10 +453,11 @@ def test_title_includes_issues_when_unit_is_issue(self): """Test that the title is formatted correctly when unit is issues.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.issues) title = output.format_slack_message().splitlines()[0] @@ -434,10 +468,11 @@ def test_title_includes_points_when_unit_is_points(self): """Test that the title is formatted correctly when unit is points.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) title = output.format_slack_message().splitlines()[0] @@ -452,10 +487,11 @@ def test_plot_results_output_stored_in_chart_property(self): """SprintBurndown.chart should contain the output of plot_results().""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurndown(test_data, sprint="Sprint 1", unit=Unit.points) # validation - check that the chart attribute matches output of plot_results() @@ -532,7 +568,7 @@ def test_post_to_slack( """Test the steps required to post the results to slack, without actually posting.""" # execution sample_burndown.post_results_to_slack( - mock_slackbot, + mock_slackbot, # type: ignore[assignment] channel_id="test_channel", output_dir=tmp_path, ) diff --git a/analytics/tests/metrics/test_burnup.py b/analytics/tests/metrics/test_burnup.py index 45eeb0184..df5f653f6 100644 --- a/analytics/tests/metrics/test_burnup.py +++ b/analytics/tests/metrics/test_burnup.py @@ -4,7 +4,7 @@ import pandas as pd import pytest -from analytics.datasets.sprint_board import SprintBoard +from analytics.datasets.issues import GitHubIssues from analytics.metrics.burnup import SprintBurnup, Unit from tests.conftest import ( @@ -14,7 +14,7 @@ DAY_3, DAY_4, MockSlackbot, - sprint_row, + issue, ) @@ -28,7 +28,7 @@ def result_row( ) -> dict: """Create a sample result row.""" return { - "date": pd.Timestamp(day, tz="UTC"), + "date": pd.Timestamp(day), "opened": opened, "closed": closed, "delta": delta, @@ -42,10 +42,11 @@ def sample_burnup_by_points_fixture() -> SprintBurnup: """Create a sample burnup to simplify test setup.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # return sprint burnup by points return SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) @@ -59,18 +60,19 @@ def test_exclude_tix_assigned_to_other_sprints(self): sprint_data = [ # fmt: off # include this row - assigned to sprint 1 - sprint_row(issue=1, sprint=1, sprint_start=DAY_1, created=DAY_1, closed=DAY_3), + issue(issue=1, sprint=1, sprint_start=DAY_1, created=DAY_1, closed=DAY_3), # exclude this row - assigned to sprint 2 - sprint_row(issue=1, sprint=2, sprint_start=DAY_4, created=DAY_0, closed=DAY_4), + issue(issue=1, sprint=2, sprint_start=DAY_4, created=DAY_0, closed=DAY_4), # fmt: on ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check min and max dates - assert df[output.date_col].min() == pd.Timestamp(DAY_1, tz="UTC") - assert df[output.date_col].max() == pd.Timestamp(DAY_3, tz="UTC") + assert df[output.date_col].min() == pd.Timestamp(DAY_1) + assert df[output.date_col].max() == pd.Timestamp(DAY_3) # validation - check burnup output expected = [ result_row( @@ -104,16 +106,17 @@ def test_count_tix_created_before_sprint_start(self): """Burnup should include tix opened before the sprint but closed during it.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check min and max dates - assert df[output.date_col].min() == pd.Timestamp(DAY_0, tz="UTC") - assert df[output.date_col].max() == pd.Timestamp(DAY_3, tz="UTC") + assert df[output.date_col].min() == pd.Timestamp(DAY_0) + assert df[output.date_col].max() == pd.Timestamp(DAY_3) # validation - check burnup output expected = [ result_row( @@ -155,14 +158,14 @@ def test_count_tix_closed_after_sprint_start(self): """Burnup should include tix closed after the sprint ended.""" # setup - create test data sprint_data = [ - sprint_row( # closed before sprint end + issue( # closed before sprint end issue=1, sprint_start=DAY_1, sprint_length=2, created=DAY_1, closed=DAY_2, ), - sprint_row( # closed after sprint end + issue( # closed after sprint end issue=1, sprint_start=DAY_1, sprint_length=2, @@ -170,13 +173,14 @@ def test_count_tix_closed_after_sprint_start(self): closed=DAY_4, ), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check min and max dates - assert df[output.date_col].min() == pd.Timestamp(DAY_1, tz="UTC") - assert df[output.date_col].max() == pd.Timestamp(DAY_4, tz="UTC") + assert df[output.date_col].min() == pd.Timestamp(DAY_1) + assert df[output.date_col].max() == pd.Timestamp(DAY_4) # validation - check burnup output expected = [ result_row( @@ -218,10 +222,11 @@ def test_count_tix_created_after_sprint_start(self): """Burnup should include tix opened and closed during the sprint.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, closed=DAY_3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, closed=DAY_3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results @@ -266,24 +271,26 @@ def test_include_all_sprint_days_if_tix_closed_early(self): """All days of the sprint should be included even if all tix were closed early.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) df = output.results # validation - check max date is end of sprint not last closed date - assert df[output.date_col].max() == pd.Timestamp(DAY_3, tz="UTC") + assert df[output.date_col].max() == pd.Timestamp(DAY_3) def test_raise_value_error_if_sprint_arg_not_in_dataset(self): """A ValueError should be raised if the sprint argument isn't valid.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_1), + issue(issue=1, sprint_start=DAY_1, created=DAY_0), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # validation with pytest.raises( ValueError, @@ -299,10 +306,11 @@ def test_calculate_burnup_for_current_sprint(self): day_2 = today.strftime("%Y-%m-%d") day_3 = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d") sprint_data = [ # note sprint duration is 2 days by default - sprint_row(issue=1, sprint_start=day_1, created=day_1, closed=day_2), - sprint_row(issue=1, sprint_start=day_1, created=day_1), + issue(issue=1, sprint_start=day_1, created=day_1, closed=day_2), + issue(issue=1, sprint_start=day_1, created=day_1), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="@current", unit=Unit.issues) df = output.results @@ -343,10 +351,11 @@ def test_burnup_works_with_points(self): """Burnup should be calculated correctly with points.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) df = output.results @@ -391,11 +400,12 @@ def test_burnup_excludes_tix_without_points(self): """Burnup should exclude tickets that are not pointed.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_1, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=0), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=None), + issue(issue=1, sprint_start=DAY_1, created=DAY_1, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=0), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=None), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) df = output.results @@ -443,16 +453,17 @@ def test_sprint_start_and_sprint_end_not_affected_by_unit(self): """Test that sprint start and end are the same regardless of unit.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), - sprint_row(issue=2, sprint_start=DAY_1, created=DAY_2, closed=DAY_4), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, closed=DAY_2), + issue(issue=2, sprint_start=DAY_1, created=DAY_2, closed=DAY_4), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution points = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) issues = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) # validation - check they're calculated correctly - assert points.stats.get(self.SPRINT_START).value == DAY_1 - assert points.stats.get(self.SPRINT_END).value == DAY_3 + assert points.stats[self.SPRINT_START].value == DAY_1 + assert points.stats[self.SPRINT_END].value == DAY_3 # validation - check that they are the same # fmt: off assert points.stats.get(self.SPRINT_START) == issues.stats.get(self.SPRINT_START) @@ -463,114 +474,119 @@ def test_get_total_closed_and_opened_when_unit_is_issues(self): """Test that total_closed is calculated correctly when unit is issues.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint=1, created=DAY_0, closed=DAY_2), - sprint_row(issue=2, sprint=1, created=DAY_0, closed=DAY_3), - sprint_row(issue=3, sprint=1, created=DAY_2), # not closed - sprint_row(issue=4, sprint=1, created=DAY_2), # not closed + issue(issue=1, sprint=1, created=DAY_0, closed=DAY_2), + issue(issue=2, sprint=1, created=DAY_0, closed=DAY_3), + issue(issue=3, sprint=1, created=DAY_2), # not closed + issue(issue=4, sprint=1, created=DAY_2), # not closed ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) print(output.results) # validation - check that stats were calculated correctly - assert output.stats.get(self.TOTAL_CLOSED).value == 2 - assert output.stats.get(self.TOTAL_OPENED).value == 4 - assert output.stats.get(self.PCT_CLOSED).value == 50.0 + assert output.stats[self.TOTAL_CLOSED].value == 2 + assert output.stats[self.TOTAL_OPENED].value == 4 + assert output.stats[self.PCT_CLOSED].value == 50.0 # validation - check that message contains string value of Unit.issues - assert Unit.issues.value in output.stats.get(self.TOTAL_CLOSED).suffix - assert Unit.issues.value in output.stats.get(self.TOTAL_OPENED).suffix - assert "%" in output.stats.get(self.PCT_CLOSED).suffix + assert Unit.issues.value in output.stats[self.TOTAL_CLOSED].suffix + assert Unit.issues.value in output.stats[self.TOTAL_OPENED].suffix + assert "%" in output.stats[self.PCT_CLOSED].suffix def test_get_total_closed_and_opened_when_unit_is_points(self): """Test that total_closed is calculated correctly when unit is issues.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), - sprint_row(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), - sprint_row(issue=3, sprint=1, created=DAY_2, points=2), # not closed - sprint_row(issue=4, sprint=1, created=DAY_2, points=4), # not closed + issue(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), + issue(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), + issue(issue=3, sprint=1, created=DAY_2, points=2), # not closed + issue(issue=4, sprint=1, created=DAY_2, points=4), # not closed ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) # validation - assert output.stats.get(self.TOTAL_CLOSED).value == 3 - assert output.stats.get(self.TOTAL_OPENED).value == 9 - assert output.stats.get(self.PCT_CLOSED).value == 33.33 # rounded to 2 places + assert output.stats[self.TOTAL_CLOSED].value == 3 + assert output.stats[self.TOTAL_OPENED].value == 9 + assert output.stats[self.PCT_CLOSED].value == 33.33 # rounded to 2 places # validation - check that message contains string value of Unit.points - assert Unit.points.value in output.stats.get(self.TOTAL_CLOSED).suffix - assert Unit.points.value in output.stats.get(self.TOTAL_OPENED).suffix - assert "%" in output.stats.get(self.PCT_CLOSED).suffix + assert Unit.points.value in output.stats[self.TOTAL_CLOSED].suffix + assert Unit.points.value in output.stats[self.TOTAL_OPENED].suffix + assert "%" in output.stats[self.PCT_CLOSED].suffix def test_include_issues_closed_after_sprint_end(self): """Issues that are closed after sprint ended should be included in closed count.""" # setup - create test data sprint_data = [ - sprint_row( # closed during sprint + issue( # closed during sprint issue=1, sprint_start=DAY_1, sprint_length=2, created=DAY_1, closed=DAY_2, ), - sprint_row( # closed after sprint + issue( # closed after sprint issue=2, sprint_start=DAY_1, sprint_length=2, created=DAY_2, closed=DAY_4, ), - sprint_row( # not closed + issue( # not closed issue=3, sprint_start=DAY_1, sprint_length=2, created=DAY_2, ), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) # validation - assert output.stats.get(self.TOTAL_CLOSED).value == 2 - assert output.stats.get(self.TOTAL_OPENED).value == 3 - assert output.stats.get(self.PCT_CLOSED).value == 66.67 # rounded to 2 places + assert output.stats[self.TOTAL_CLOSED].value == 2 + assert output.stats[self.TOTAL_OPENED].value == 3 + assert output.stats[self.PCT_CLOSED].value == 66.67 # rounded to 2 places def test_get_percent_pointed(self): """Test that percent pointed is calculated correctly.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), - sprint_row(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), - sprint_row(issue=3, sprint=1, created=DAY_2, points=None), # not pointed - sprint_row(issue=4, sprint=1, created=DAY_2, points=0), # not closed + issue(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), + issue(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), + issue(issue=3, sprint=1, created=DAY_2, points=None), # not pointed + issue(issue=4, sprint=1, created=DAY_2, points=0), # not closed ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) # validation - assert output.stats.get(self.TOTAL_CLOSED).value == 3 - assert output.stats.get(self.TOTAL_OPENED).value == 3 - assert output.stats.get(self.PCT_CLOSED).value == 100 - assert output.stats.get(self.PCT_POINTED).value == 50 + assert output.stats[self.TOTAL_CLOSED].value == 3 + assert output.stats[self.TOTAL_OPENED].value == 3 + assert output.stats[self.PCT_CLOSED].value == 100 + assert output.stats[self.PCT_POINTED].value == 50 # validation - check that stat contains '%' suffix - assert f"% of {Unit.issues.value}" in output.stats.get(self.PCT_POINTED).suffix + assert f"% of {Unit.issues.value}" in output.stats[self.PCT_POINTED].suffix def test_exclude_other_sprints_in_percent_pointed(self): """Only include issues in this sprint when calculating percent pointed.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), - sprint_row(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), - sprint_row(issue=3, sprint=1, created=DAY_2, points=None), # not pointed - sprint_row(issue=4, sprint=2, created=DAY_2, points=None), # other sprint + issue(issue=1, sprint=1, created=DAY_1, points=2, closed=DAY_2), + issue(issue=2, sprint=1, created=DAY_2, points=1, closed=DAY_4), + issue(issue=3, sprint=1, created=DAY_2, points=None), # not pointed + issue(issue=4, sprint=2, created=DAY_2, points=None), # other sprint ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) # validation - assert output.stats.get(self.TOTAL_CLOSED).value == 2 - assert output.stats.get(self.TOTAL_OPENED).value == 3 - assert output.stats.get(self.PCT_POINTED).value == 66.67 # exclude final row + assert output.stats[self.TOTAL_CLOSED].value == 2 + assert output.stats[self.TOTAL_OPENED].value == 3 + assert output.stats[self.PCT_POINTED].value == 66.67 # exclude final row class TestFormatSlackMessage: @@ -580,10 +596,11 @@ def test_slack_message_contains_right_number_of_lines(self): """Message should contain one line for the title and one for each stat.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) lines = output.format_slack_message().splitlines() @@ -596,10 +613,11 @@ def test_title_includes_issues_when_unit_is_issue(self): """Test that the title is formatted correctly when unit is issues.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.issues) title = output.format_slack_message().splitlines()[0] @@ -610,10 +628,11 @@ def test_title_includes_points_when_unit_is_points(self): """Test that the title is formatted correctly when unit is points.""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) title = output.format_slack_message().splitlines()[0] @@ -628,10 +647,11 @@ def test_plot_results_output_stored_in_chart_property(self): """SprintBurnup.chart should contain the output of plot_results().""" # setup - create test data sprint_data = [ - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), - sprint_row(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), + issue(issue=1, sprint_start=DAY_1, created=DAY_0, points=2), + issue(issue=1, sprint_start=DAY_1, created=DAY_2, points=3), ] - test_data = SprintBoard.from_dict(sprint_data) + sprint_data = [i.__dict__ for i in sprint_data] + test_data = GitHubIssues.from_dict(sprint_data) # execution output = SprintBurnup(test_data, sprint="Sprint 1", unit=Unit.points) # validation - check that the chart attribute matches output of plot_results() @@ -708,7 +728,7 @@ def test_post_to_slack( """Test the steps required to post the results to slack, without actually posting.""" # execution sample_burnup.post_results_to_slack( - mock_slackbot, + mock_slackbot, # type: ignore[assignment] channel_id="test_channel", output_dir=tmp_path, ) diff --git a/analytics/tests/test_cli.py b/analytics/tests/test_cli.py index b3eef46a3..eb51e7c22 100644 --- a/analytics/tests/test_cli.py +++ b/analytics/tests/test_cli.py @@ -10,6 +10,7 @@ from tests.conftest import ( json_issue_row, json_sprint_row, + issue, write_test_data_to_file, ) @@ -22,6 +23,7 @@ class MockFiles: issue_file: Path sprint_file: Path + delivery_file: Path @pytest.fixture(name="mock_files") @@ -30,15 +32,21 @@ def test_file_fixtures(tmp_path: Path) -> MockFiles: # set paths to test files issue_file = tmp_path / "data" / "issue-data.json" sprint_file = tmp_path / "data" / "sprint-data.json" + delivery_file = tmp_path / "data" / "delivery-data.json" # create test data sprint_data = [json_sprint_row(issue=1, parent_number=2)] issue_data = [ json_issue_row(issue=1, labels=["task"]), json_issue_row(issue=2, labels=["deliverable: 30k ft"]), ] + delivery_data = [ + issue(issue=1).__dict__, + issue(issue=2).__dict__, + ] # write test data to json files write_test_data_to_file(issue_data, issue_file) write_test_data_to_file({"items": sprint_data}, sprint_file) + write_test_data_to_file(delivery_data, delivery_file) # confirm the data was written assert issue_file.exists() assert sprint_file.exists() @@ -46,6 +54,7 @@ def test_file_fixtures(tmp_path: Path) -> MockFiles: return MockFiles( issue_file=issue_file, sprint_file=sprint_file, + delivery_file=delivery_file, ) @@ -58,10 +67,8 @@ def test_without_showing_or_posting_results(self, mock_files: MockFiles): command = [ "calculate", "sprint_burndown", - "--sprint-file", - str(mock_files.sprint_file), "--issue-file", - str(mock_files.issue_file), + str(mock_files.delivery_file), "--sprint", "Sprint 1", ] @@ -81,10 +88,8 @@ def test_stdout_message_includes_points_if_no_unit_is_set( command = [ "calculate", "sprint_burndown", - "--sprint-file", - str(mock_files.sprint_file), "--issue-file", - str(mock_files.issue_file), + str(mock_files.delivery_file), "--sprint", "Sprint 1", "--show-results", @@ -107,10 +112,80 @@ def test_stdout_message_includes_issues_if_unit_set_to_issues( command = [ "calculate", "sprint_burndown", - "--sprint-file", - str(mock_files.sprint_file), "--issue-file", - str(mock_files.issue_file), + str(mock_files.delivery_file), + "--sprint", + "Sprint 1", + "--unit", + "issues", + "--show-results", + ] + # execution + result = runner.invoke(app, command) + print(result.stdout) + # validation - check there wasn't an error + assert result.exit_code == 0 + # validation - check that slack message is printed and includes 'points' + assert "Slack message" in result.stdout + assert "issues" in result.stdout + + +class TestCalculateSprintBurnup: + """Test the calculate_sprint_burnup entry point with mock data.""" + + def test_without_showing_or_posting_results(self, mock_files: MockFiles): + """Entrypoint should run successfully but not print slack message to stdout.""" + # setup - create command + command = [ + "calculate", + "sprint_burnup", + "--issue-file", + str(mock_files.delivery_file), + "--sprint", + "Sprint 1", + ] + # execution + result = runner.invoke(app, command) + print(result.stdout) + # validation - check there wasn't an error + assert result.exit_code == 0 + assert "Slack message" not in result.stdout + + def test_stdout_message_includes_points_if_no_unit_is_set( + self, + mock_files: MockFiles, + ): + """CLI should uses 'points' as default unit and include it in stdout message.""" + # setup - create command + command = [ + "calculate", + "sprint_burnup", + "--issue-file", + str(mock_files.delivery_file), + "--sprint", + "Sprint 1", + "--show-results", + ] + # execution + result = runner.invoke(app, command) + print(result.stdout) + # validation - check there wasn't an error + assert result.exit_code == 0 + # validation - check that slack message is printed and includes 'points' + assert "Slack message" in result.stdout + assert "points" in result.stdout + + def test_stdout_message_includes_issues_if_unit_set_to_issues( + self, + mock_files: MockFiles, + ): + """CLI should use issues if set explicitly and include it in stdout.""" + # setup - create command + command = [ + "calculate", + "sprint_burnup", + "--issue-file", + str(mock_files.delivery_file), "--sprint", "Sprint 1", "--unit",