From 4b6b4bca29423be92b0dbd407a33cf03b63589ff Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Wed, 23 Oct 2024 12:37:11 -0700 Subject: [PATCH] feat: add get_stats_time_in_draft in order to calculate the overall draft time stats for multiple issues Signed-off-by: Zack Koppert --- issue_metrics.py | 24 +++++++------- json_writer.py | 77 +++++++++++++++++++++++++++++-------------- test_json_writer.py | 14 ++++++++ test_time_in_draft.py | 54 +++++++++++++++++++++++++++++- time_in_draft.py | 46 ++++++++++++++++++++++++-- 5 files changed, 176 insertions(+), 39 deletions(-) diff --git a/issue_metrics.py b/issue_metrics.py index a534f9c..4d2fc96 100644 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -30,7 +30,7 @@ from markdown_writer import write_to_markdown from most_active_mentors import count_comments_per_user, get_mentor_count from search import get_owners_and_repositories, search_issues -from time_in_draft import measure_time_in_draft +from time_in_draft import get_stats_time_in_draft, measure_time_in_draft from time_to_answer import get_stats_time_to_answer, measure_time_to_answer from time_to_close import get_stats_time_to_close, measure_time_to_close from time_to_first_response import ( @@ -299,6 +299,7 @@ def main(): # pragma: no cover stats_time_to_close = get_stats_time_to_close(issues_with_metrics) stats_time_to_answer = get_stats_time_to_answer(issues_with_metrics) + stats_time_in_draft = get_stats_time_in_draft(issues_with_metrics) num_mentor_count = 0 if enable_mentor_count: @@ -310,16 +311,17 @@ def main(): # pragma: no cover # Write the results to json and a markdown file write_to_json( - issues_with_metrics, - stats_time_to_first_response, - stats_time_to_close, - stats_time_to_answer, - stats_time_in_labels, - num_issues_open, - num_issues_closed, - num_mentor_count, - search_query, - output_file, + issues_with_metrics=issues_with_metrics, + stats_time_to_first_response=stats_time_to_first_response, + stats_time_to_close=stats_time_to_close, + stats_time_to_answer=stats_time_to_answer, + stats_time_in_draft=stats_time_in_draft, + stats_time_in_labels=stats_time_in_labels, + num_issues_opened=num_issues_open, + num_issues_closed=num_issues_closed, + num_mentor_count=num_mentor_count, + search_query=search_query, + output_file=output_file, ) write_to_markdown( diff --git a/json_writer.py b/json_writer.py index 5655feb..015dbe4 100644 --- a/json_writer.py +++ b/json_writer.py @@ -2,12 +2,15 @@ Functions: write_to_json( - issues_with_metrics: List[IssueWithMetrics], - average_time_to_first_response: timedelta, - average_time_to_close: timedelta, - average_time_to_answer: timedelta, - num_issues_opened: int, - num_issues_closed: int, + issues_with_metrics: Union[List[IssueWithMetrics], None], + stats_time_to_first_response: Union[dict[str, timedelta], None], + stats_time_to_close: Union[dict[str, timedelta], None], + stats_time_to_answer: Union[dict[str, timedelta], None], + stats_time_in_draft: Union[dict[str, timedelta], None], + stats_time_in_labels: Union[dict[str, dict[str, timedelta]], None], + num_issues_opened: Union[int, None], + num_issues_closed: Union[int, None], + num_mentor_count: Union[int, None], search_query: str, output_file: str, ) -> str: @@ -28,6 +31,7 @@ def write_to_json( stats_time_to_first_response: Union[dict[str, timedelta], None], stats_time_to_close: Union[dict[str, timedelta], None], stats_time_to_answer: Union[dict[str, timedelta], None], + stats_time_in_draft: Union[dict[str, timedelta], None], stats_time_in_labels: Union[dict[str, dict[str, timedelta]], None], num_issues_opened: Union[int, None], num_issues_closed: Union[int, None], @@ -40,38 +44,48 @@ def write_to_json( json structure is like following { - "average_time_to_first_response": "2 days, 12:00:00", - "average_time_to_close": "5 days, 0:00:00", - "average_time_to_answer": "1 day, 0:00:00", + "average_time_to_first_response": "None", + "average_time_to_close": "None", + "average_time_to_answer": "None", + "average_time_in_draft": "None", + "average_time_in_labels": {}, + "median_time_to_first_response": "None", + "median_time_to_close": "None", + "median_time_to_answer": "None", + "median_time_in_draft": "None", + "median_time_in_labels": {}, + "90_percentile_time_to_first_response": "None", + "90_percentile_time_to_close": "None", + "90_percentile_time_to_answer": "None", + "90_percentile_time_in_draft": "None", + "90_percentile_time_in_labels": {}, "num_items_opened": 2, - "num_items_closed": 1, + "num_items_closed": 0, "num_mentor_count": 5, "total_item_count": 2, "issues": [ { "title": "Issue 1", "html_url": "https://github.com/owner/repo/issues/1", - "author": "author", - "time_to_first_response": "3 days, 0:00:00", - "time_to_close": "6 days, 0:00:00", + "author": "alice", + "time_to_first_response": "None", + "time_to_close": "None", "time_to_answer": "None", - "time_in_draft": "1 day, 0:00:00", - "label_metrics": { - "bug": "1 day, 16:24:12" - } + "time_in_draft": "None", + "label_metrics": {} }, { "title": "Issue 2", "html_url": "https://github.com/owner/repo/issues/2", - "author": "author", - "time_to_first_response": "2 days, 0:00:00", - "time_to_close": "4 days, 0:00:00", - "time_to_answer": "1 day, 0:00:00", - "label_metrics": { - } - }, + "author": "bob", + "time_to_first_response": "None", + "time_to_close": "None", + "time_to_answer": "None", + "time_in_draft": "None", + "label_metrics": {} + } ], - "search_query": "is:issue is:open repo:owner/repo" + "search_query": "is:issue repo:owner/repo" } """ @@ -107,6 +121,16 @@ def write_to_json( med_time_to_answer = stats_time_to_answer["med"] p90_time_to_answer = stats_time_to_answer["90p"] + # time in draft + average_time_in_draft = None + med_time_in_draft = None + p90_time_in_draft = None + if stats_time_in_draft is not None: + average_time_in_draft = stats_time_in_draft["avg"] + med_time_in_draft = stats_time_in_draft["med"] + p90_time_in_draft = stats_time_in_draft["90p"] + + # time in labels average_time_in_labels = {} med_time_in_labels = {} p90_time_in_labels = {} @@ -123,14 +147,17 @@ def write_to_json( "average_time_to_first_response": str(average_time_to_first_response), "average_time_to_close": str(average_time_to_close), "average_time_to_answer": str(average_time_to_answer), + "average_time_in_draft": str(average_time_in_draft), "average_time_in_labels": average_time_in_labels, "median_time_to_first_response": str(med_time_to_first_response), "median_time_to_close": str(med_time_to_close), "median_time_to_answer": str(med_time_to_answer), + "median_time_in_draft": str(med_time_in_draft), "median_time_in_labels": med_time_in_labels, "90_percentile_time_to_first_response": str(p90_time_to_first_response), "90_percentile_time_to_close": str(p90_time_to_close), "90_percentile_time_to_answer": str(p90_time_to_answer), + "90_percentile_time_in_draft": str(p90_time_in_draft), "90_percentile_time_in_labels": p90_time_in_labels, "num_items_opened": num_issues_opened, "num_items_closed": num_issues_closed, diff --git a/test_json_writer.py b/test_json_writer.py index 36d1faa..1525d6c 100644 --- a/test_json_writer.py +++ b/test_json_writer.py @@ -55,6 +55,11 @@ def test_write_to_json(self): "med": timedelta(days=2), "90p": timedelta(days=3), } + stats_time_in_draft = { + "avg": timedelta(days=1), + "med": timedelta(days=1), + "90p": timedelta(days=1), + } stats_time_in_labels = { "avg": {"bug": timedelta(days=1, hours=16, minutes=24, seconds=12)}, "med": {"bug": timedelta(days=1, hours=16, minutes=24, seconds=12)}, @@ -68,14 +73,17 @@ def test_write_to_json(self): "average_time_to_first_response": "2 days, 12:00:00", "average_time_to_close": "5 days, 0:00:00", "average_time_to_answer": "1 day, 0:00:00", + "average_time_in_draft": "1 day, 0:00:00", "average_time_in_labels": {"bug": "1 day, 16:24:12"}, "median_time_to_first_response": "2 days, 12:00:00", "median_time_to_close": "4 days, 0:00:00", "median_time_to_answer": "2 days, 0:00:00", + "median_time_in_draft": "1 day, 0:00:00", "median_time_in_labels": {"bug": "1 day, 16:24:12"}, "90_percentile_time_to_first_response": "1 day, 12:00:00", "90_percentile_time_to_close": "3 days, 0:00:00", "90_percentile_time_to_answer": "3 days, 0:00:00", + "90_percentile_time_in_draft": "1 day, 0:00:00", "90_percentile_time_in_labels": {"bug": "1 day, 16:24:12"}, "num_items_opened": 2, "num_items_closed": 1, @@ -113,6 +121,7 @@ def test_write_to_json(self): stats_time_to_first_response=stats_time_to_first_response, stats_time_to_close=stats_time_to_close, stats_time_to_answer=stats_time_to_answer, + stats_time_in_draft=stats_time_in_draft, stats_time_in_labels=stats_time_in_labels, num_issues_opened=num_issues_opened, num_issues_closed=num_issues_closed, @@ -154,6 +163,7 @@ def test_write_to_json_with_no_response(self): "med": {}, "90p": {}, } + stats_time_in_draft = None num_issues_opened = 2 num_issues_closed = 0 num_mentor_count = 5 @@ -162,14 +172,17 @@ def test_write_to_json_with_no_response(self): "average_time_to_first_response": "None", "average_time_to_close": "None", "average_time_to_answer": "None", + "average_time_in_draft": "None", "average_time_in_labels": {}, "median_time_to_first_response": "None", "median_time_to_close": "None", "median_time_to_answer": "None", + "median_time_in_draft": "None", "median_time_in_labels": {}, "90_percentile_time_to_first_response": "None", "90_percentile_time_to_close": "None", "90_percentile_time_to_answer": "None", + "90_percentile_time_in_draft": "None", "90_percentile_time_in_labels": {}, "num_items_opened": 2, "num_items_closed": 0, @@ -207,6 +220,7 @@ def test_write_to_json_with_no_response(self): stats_time_to_first_response=stats_time_to_first_response, stats_time_to_close=stats_time_to_close, stats_time_to_answer=stats_time_to_answer, + stats_time_in_draft=stats_time_in_draft, stats_time_in_labels=stats_time_in_labels, num_issues_opened=num_issues_opened, num_issues_closed=num_issues_closed, diff --git a/test_time_in_draft.py b/test_time_in_draft.py index 17ced10..f05ec26 100644 --- a/test_time_in_draft.py +++ b/test_time_in_draft.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock import pytz -from time_in_draft import measure_time_in_draft +from time_in_draft import get_stats_time_in_draft, measure_time_in_draft class TestMeasureTimeInDraft(unittest.TestCase): @@ -56,5 +56,57 @@ def test_time_in_draft_with_no_events(self): self.assertIsNone(result, "The result should be None when there are no events.") +class TestGetStatsTimeInDraft(unittest.TestCase): + """ + Unit tests for the get_stats_time_in_draft function. + """ + + def test_get_stats_time_in_draft_with_data(self): + """ + Test get_stats_time_in_draft with valid draft times. + """ + issues = [ + MagicMock(time_in_draft=timedelta(days=1)), + MagicMock(time_in_draft=timedelta(days=2)), + MagicMock(time_in_draft=timedelta(days=3)), + ] + + result = get_stats_time_in_draft(issues) + expected = { + "avg": timedelta(days=2), + "med": timedelta(days=2), + "90p": timedelta(days=2, seconds=69120), + } + + self.assertEqual( + result, expected, "The statistics for time in draft are incorrect." + ) + + def test_get_stats_time_in_draft_no_data(self): + """ + Test get_stats_time_in_draft with no draft times. + """ + issues = [ + MagicMock(time_in_draft=None), + MagicMock(time_in_draft=None), + ] + + result = get_stats_time_in_draft(issues) + self.assertIsNone( + result, "The result should be None when there are no draft times." + ) + + def test_get_stats_time_in_draft_empty_list(self): + """ + Test get_stats_time_in_draft with an empty list of issues. + """ + issues = [] + + result = get_stats_time_in_draft(issues) + self.assertIsNone( + result, "The result should be None when the list of issues is empty." + ) + + if __name__ == "__main__": unittest.main() diff --git a/time_in_draft.py b/time_in_draft.py index cef3a9b..1650fd5 100644 --- a/time_in_draft.py +++ b/time_in_draft.py @@ -2,11 +2,13 @@ This module contains a function that measures the time a pull request has been in draft state. """ -from datetime import datetime -from typing import Union +from datetime import datetime, timedelta +from typing import List, Union import github3 +import numpy import pytz +from classes import IssueWithMetrics def measure_time_in_draft( @@ -42,3 +44,43 @@ def measure_time_in_draft( return None return None + + +def get_stats_time_in_draft( + issues_with_metrics: List[IssueWithMetrics], +) -> Union[dict[str, timedelta], None]: + """ + Calculate stats describing the time in draft for a list of issues. + """ + # Filter out issues with no time to answer + issues_with_time_to_draft = [ + issue for issue in issues_with_metrics if issue.time_in_draft is not None + ] + + # Calculate the total time in draft for all issues + draft_times = [] + if issues_with_time_to_draft: + for issue in issues_with_time_to_draft: + if issue.time_in_draft: + draft_times.append(issue.time_in_draft.total_seconds()) + + # Calculate stats describing time to answer + num_issues_with_time_in_draft = len(issues_with_time_to_draft) + if num_issues_with_time_in_draft > 0: + average_time_in_draft = numpy.round(numpy.average(draft_times)) + med_time_in_draft = numpy.round(numpy.median(draft_times)) + ninety_percentile_time_in_draft = numpy.round( + numpy.percentile(draft_times, 90, axis=0) + ) + else: + return None + + stats = { + "avg": timedelta(seconds=average_time_in_draft), + "med": timedelta(seconds=med_time_in_draft), + "90p": timedelta(seconds=ninety_percentile_time_in_draft), + } + + # Print the average time to answer converting seconds to a readable time format + print(f"Average time to answer: {timedelta(seconds=average_time_in_draft)}") + return stats