From 6e0c7ea7af9ff68dfe27703a9a4f449c667cb37a Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:10:28 +0000 Subject: [PATCH 1/9] feat: add time_to_first_review metric for pull requests --- classes.py | 2 + issue_metrics.py | 6 ++- time_to_first_review.py | 84 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 time_to_first_review.py diff --git a/classes.py b/classes.py index bc2df19..b60ad2e 100644 --- a/classes.py +++ b/classes.py @@ -36,6 +36,7 @@ def __init__( html_url, author, time_to_first_response=None, + # time_to_first_review=None, time_to_close=None, time_to_answer=None, time_in_draft=None, @@ -53,6 +54,7 @@ def __init__( self.assignee = assignee self.assignees = assignees or [] self.time_to_first_response = time_to_first_response + self.time_to_first_review = None self.time_to_close = time_to_close self.time_to_answer = time_to_answer self.time_in_draft = time_in_draft diff --git a/issue_metrics.py b/issue_metrics.py index 2e2b9d0..f1cd85d 100755 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -39,6 +39,7 @@ get_stats_time_to_first_response, measure_time_to_first_response, ) +from time_to_first_review import measure_time_to_first_review from time_to_merge import measure_time_to_merge from time_to_ready_for_review import get_time_to_ready_for_review @@ -159,7 +160,10 @@ def get_per_issue_metrics( issue_with_metrics.pr_comment_count = count_pr_comments( issue, pull_request, ignore_users ) - + if pull_request: + issue_with_metrics.time_to_first_review = measure_time_to_first_review( + issue, pull_request, ready_for_review_at, ignore_users + ) if env_vars.hide_time_to_first_response is False: issue_with_metrics.time_to_first_response = ( measure_time_to_first_response( diff --git a/time_to_first_review.py b/time_to_first_review.py new file mode 100644 index 0000000..066de51 --- /dev/null +++ b/time_to_first_review.py @@ -0,0 +1,84 @@ +from datetime import datetime, timedelta +from typing import List, Union + +import github3 +import numpy +from classes import IssueWithMetrics +from time_to_first_response import ignore_comment + + +def measure_time_to_first_review( + issue: Union[github3.issues.Issue, None], + pull_request: Union[github3.pulls.PullRequest, None], + ready_for_review_at: Union[datetime, None] = None, + ignore_users: Union[List[str], None] = None, +) -> Union[timedelta, None]: + '''Measures duration between pull request creation time and the timestamp when the first review is submitted''' + + if not issue or not pull_request: + return None + + if ignore_users is None: + ignore_users = [] + + '''first_review_time = None''' + + try: + reviews = pull_request.reviews(number=50) + for review in reviews: + if ignore_comment( + issue.issue.user, + review.user, + ignore_users, + review.submitted_at, + ready_for_review_at, + ): + continue + + first_review_time = review.submitted_at + break + + except TypeError: + return None + + if not first_review_time: + return None + + if ready_for_review_at: + pr_created_time = ready_for_review_at + else: + pr_created_time = datetime.fromisoformat(issue.created_at) + + return first_review_time - pr_created_time + +def get_stats_time_to_first_review( + issues: List[IssueWithMetrics], +) -> Union[dict[str, timedelta], None]: + + review_times = [] + none_count = 0 + for issue in issues: + if issue.time_to_first_review: + review_times.append(issue.time_to_first_review.total_seconds()) + else: + none_count += 1 + + if len(issues) - none_count <= 0: + return None + + average_seconds_to_first_review = numpy.round(numpy.average(review_times)) + med_seconds_to_first_review = numpy.round(numpy.median(review_times)) + ninety_percentile_seconds_to_first_review = numpy.round(numpy.percentile(review_times, 90, axis=0)) + + stats = { + "avg": timedelta(seconds=average_seconds_to_first_review), + "med": timedelta(seconds=med_seconds_to_first_review), + "90p": timedelta(seconds=ninety_percentile_seconds_to_first_review), + } + + # Print the average time to first review converting seconds to a readable time format + print( + f"Average time to first review: {timedelta(seconds=average_seconds_to_first_review)}" + ) + + return stats From 346e6b57d8b9fc11bb4044bec56928899ca88a54 Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:42:07 +0000 Subject: [PATCH 2/9] fix: add tests, remove comment and lint fixed --- classes.py | 1 - test_time_to_first_review.py | 63 ++++++++++++++++++++++++++++++++++++ time_to_first_review.py | 12 ++++--- 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 test_time_to_first_review.py diff --git a/classes.py b/classes.py index b60ad2e..fc575ef 100644 --- a/classes.py +++ b/classes.py @@ -36,7 +36,6 @@ def __init__( html_url, author, time_to_first_response=None, - # time_to_first_review=None, time_to_close=None, time_to_answer=None, time_in_draft=None, diff --git a/test_time_to_first_review.py b/test_time_to_first_review.py new file mode 100644 index 0000000..bfbbf0d --- /dev/null +++ b/test_time_to_first_review.py @@ -0,0 +1,63 @@ +"""Unit tests for the time_to_first_review module.""" + +import unittest +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +from time_to_first_review import measure_time_to_first_review + + +class TestMeasureTimeToFirstReview(unittest.TestCase): + """Test the measure_time_to_first_review function.""" + + def test_measure_time_to_first_review_basic(self): + """Test that the function calculates correct review time.""" + + mock_issue = MagicMock() + mock_issue.created_at = "2023-01-01T00:00:00Z" + + mock_review = MagicMock() + mock_review.submitted_at = datetime.fromisoformat("2023-01-02T00:00:00Z") + + mock_pull_request = MagicMock() + mock_pull_request.reviews.return_value = [mock_review] + + result = measure_time_to_first_review(mock_issue, mock_pull_request, None, []) + + expected = timedelta(days=1) + + self.assertEqual(result, expected) + + def test_measure_time_to_first_review_no_reviews(self): + """Test that function returns None if there are no reviews.""" + + mock_issue = MagicMock() + mock_issue.created_at = "2023-01-01T00:00:00Z" + + mock_pull_request = MagicMock() + mock_pull_request.reviews.return_value = [] + + result = measure_time_to_first_review(mock_issue, mock_pull_request, None, []) + + self.assertEqual(result, None) + + def test_measure_time_to_first_review_ignore_pending(self): + """Test that pending reviews are ignored.""" + + mock_issue = MagicMock() + mock_issue.created_at = "2023-01-01T00:00:00Z" + + pending_review = MagicMock() + pending_review.submitted_at = None + + valid_review = MagicMock() + valid_review.submitted_at = datetime.fromisoformat("2023-01-03T00:00:00Z") + + mock_pull_request = MagicMock() + mock_pull_request.reviews.return_value = [pending_review, valid_review] + + result = measure_time_to_first_review(mock_issue, mock_pull_request, None, []) + + expected = timedelta(days=2) + + self.assertEqual(result, expected) diff --git a/time_to_first_review.py b/time_to_first_review.py index 066de51..9843f11 100644 --- a/time_to_first_review.py +++ b/time_to_first_review.py @@ -3,6 +3,7 @@ import github3 import numpy + from classes import IssueWithMetrics from time_to_first_response import ignore_comment @@ -13,7 +14,7 @@ def measure_time_to_first_review( ready_for_review_at: Union[datetime, None] = None, ignore_users: Union[List[str], None] = None, ) -> Union[timedelta, None]: - '''Measures duration between pull request creation time and the timestamp when the first review is submitted''' + """Measures duration between pull request creation time and the timestamp when the first review is submitted""" if not issue or not pull_request: return None @@ -21,7 +22,7 @@ def measure_time_to_first_review( if ignore_users is None: ignore_users = [] - '''first_review_time = None''' + """first_review_time = None""" try: reviews = pull_request.reviews(number=50) @@ -51,10 +52,11 @@ def measure_time_to_first_review( return first_review_time - pr_created_time + def get_stats_time_to_first_review( issues: List[IssueWithMetrics], ) -> Union[dict[str, timedelta], None]: - + review_times = [] none_count = 0 for issue in issues: @@ -68,7 +70,9 @@ def get_stats_time_to_first_review( average_seconds_to_first_review = numpy.round(numpy.average(review_times)) med_seconds_to_first_review = numpy.round(numpy.median(review_times)) - ninety_percentile_seconds_to_first_review = numpy.round(numpy.percentile(review_times, 90, axis=0)) + ninety_percentile_seconds_to_first_review = numpy.round( + numpy.percentile(review_times, 90, axis=0) + ) stats = { "avg": timedelta(seconds=average_seconds_to_first_review), From fe0b84e0b61a141f5870827923616338b7bf98e8 Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:54:00 +0000 Subject: [PATCH 3/9] fix: initialize first_review_time to None --- time_to_first_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/time_to_first_review.py b/time_to_first_review.py index 9843f11..4488c58 100644 --- a/time_to_first_review.py +++ b/time_to_first_review.py @@ -22,7 +22,7 @@ def measure_time_to_first_review( if ignore_users is None: ignore_users = [] - """first_review_time = None""" + first_review_time = None try: reviews = pull_request.reviews(number=50) @@ -42,7 +42,7 @@ def measure_time_to_first_review( except TypeError: return None - if not first_review_time: + if first_review_time is None: return None if ready_for_review_at: From 694d390dc103935963ca6370a5c478d771886a7b Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:18:02 +0000 Subject: [PATCH 4/9] fix: resolve lint issues and formatting --- issue_metrics.py | 17 ++++++++++------- time_to_first_review.py | 4 +++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/issue_metrics.py b/issue_metrics.py index 7e36f2b..d47531f 100755 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -21,13 +21,15 @@ import github3 import github3.structs + from auth import auth_to_github, get_github_app_installation_token from classes import IssueWithMetrics from config import EnvVars, get_env_vars from discussions import get_discussions from json_writer import write_to_json from labels import get_label_metrics, get_stats_time_in_labels -from markdown_helpers import markdown_too_large_for_issue_body, split_markdown_file +from markdown_helpers import (markdown_too_large_for_issue_body, + split_markdown_file) from markdown_writer import write_to_markdown from most_active_mentors import count_comments_per_user, get_mentor_count from pr_comments import count_pr_comments, get_stats_pr_comments @@ -35,10 +37,8 @@ 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 ( - get_stats_time_to_first_response, - measure_time_to_first_response, -) +from time_to_first_response import (get_stats_time_to_first_response, + measure_time_to_first_response) from time_to_first_review import measure_time_to_first_review from time_to_merge import measure_time_to_merge from time_to_ready_for_review import get_time_to_ready_for_review @@ -162,8 +162,11 @@ def get_per_issue_metrics( ) if pull_request: issue_with_metrics.time_to_first_review = measure_time_to_first_review( - issue, pull_request, ready_for_review_at, ignore_users - ) + issue, + pull_request, + ready_for_review_at, + ignore_users, + ) if env_vars.hide_time_to_first_response is False: issue_with_metrics.time_to_first_response = ( measure_time_to_first_response( diff --git a/time_to_first_review.py b/time_to_first_review.py index 4488c58..6ba4f64 100644 --- a/time_to_first_review.py +++ b/time_to_first_review.py @@ -1,3 +1,5 @@ +"""Utilities for measuring time to first review for pull requests.""" + from datetime import datetime, timedelta from typing import List, Union @@ -56,7 +58,7 @@ def measure_time_to_first_review( def get_stats_time_to_first_review( issues: List[IssueWithMetrics], ) -> Union[dict[str, timedelta], None]: - + """Compute statistics (average, median, 90th percentile) for time to first review.""" review_times = [] none_count = 0 for issue in issues: From 5c98ea6cac1fcf20b1e0ec1ce008c8f08879d84d Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:29:20 +0000 Subject: [PATCH 5/9] fix: resolve remaining lint issues --- issue_metrics.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/issue_metrics.py b/issue_metrics.py index d47531f..4f4c1c1 100755 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -28,8 +28,7 @@ from discussions import get_discussions from json_writer import write_to_json from labels import get_label_metrics, get_stats_time_in_labels -from markdown_helpers import (markdown_too_large_for_issue_body, - split_markdown_file) +from markdown_helpers import markdown_too_large_for_issue_body, split_markdown_file from markdown_writer import write_to_markdown from most_active_mentors import count_comments_per_user, get_mentor_count from pr_comments import count_pr_comments, get_stats_pr_comments @@ -37,8 +36,10 @@ 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 (get_stats_time_to_first_response, - measure_time_to_first_response) +from time_to_first_response import ( + get_stats_time_to_first_response, + measure_time_to_first_response, +) from time_to_first_review import measure_time_to_first_review from time_to_merge import measure_time_to_merge from time_to_ready_for_review import get_time_to_ready_for_review From f8b44d71a4c71d72913e4a3a20c8403bceced28a Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:12:07 +0000 Subject: [PATCH 6/9] fix: resolve isort formatting errors by removing blank lines --- issue_metrics.py | 1 - time_to_first_review.py | 1 - 2 files changed, 2 deletions(-) diff --git a/issue_metrics.py b/issue_metrics.py index 4f4c1c1..276f6bd 100755 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -21,7 +21,6 @@ import github3 import github3.structs - from auth import auth_to_github, get_github_app_installation_token from classes import IssueWithMetrics from config import EnvVars, get_env_vars diff --git a/time_to_first_review.py b/time_to_first_review.py index 6ba4f64..a8c96ac 100644 --- a/time_to_first_review.py +++ b/time_to_first_review.py @@ -5,7 +5,6 @@ import github3 import numpy - from classes import IssueWithMetrics from time_to_first_response import ignore_comment From 627921c8f7a95bc9874637de4f20241d46bddeed Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:35:43 +0000 Subject: [PATCH 7/9] feat: add time_to_first_review in json and markdown --- config.py | 6 ++++++ issue_metrics.py | 13 +++++++++++-- json_writer.py | 13 +++++++++++++ markdown_writer.py | 21 +++++++++++++++++++++ time_to_first_review.py | 6 +++++- 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/config.py b/config.py index d756290..d2fe279 100644 --- a/config.py +++ b/config.py @@ -39,6 +39,7 @@ class EnvVars: hide_time_to_close (bool): If true, the time to close metric is hidden in the output hide_time_to_first_response (bool): If true, the time to first response metric is hidden in the output + hide_time_to_first_review (bool): If true, the time to first review metric is hidden in the output hide_created_at (bool): If true, the created at timestamp is hidden in the output hide_status (bool): If true, the status column is hidden in the output ignore_users (List[str]): List of usernames to ignore when calculating metrics @@ -79,6 +80,7 @@ def __init__( hide_time_to_answer: bool, hide_time_to_close: bool, hide_time_to_first_response: bool, + hide_time_to_first_review: bool, hide_created_at: bool, hide_status: bool, ignore_user: List[str], @@ -114,6 +116,7 @@ def __init__( self.hide_time_to_answer = hide_time_to_answer self.hide_time_to_close = hide_time_to_close self.hide_time_to_first_response = hide_time_to_first_response + self.hide_time_to_first_review = hide_time_to_first_review self.hide_created_at = hide_created_at self.hide_status = hide_status self.enable_mentor_count = enable_mentor_count @@ -148,6 +151,7 @@ def __repr__(self): f"{self.hide_time_to_answer}, " f"{self.hide_time_to_close}, " f"{self.hide_time_to_first_response}, " + f"{self.hide_time_to_first_review}, " f"{self.hide_created_at}, " f"{self.hide_status}, " f"{self.ignore_users}, " @@ -269,6 +273,7 @@ def get_env_vars(test: bool = False) -> EnvVars: hide_time_to_answer = get_bool_env_var("HIDE_TIME_TO_ANSWER", False) hide_time_to_close = get_bool_env_var("HIDE_TIME_TO_CLOSE", False) hide_time_to_first_response = get_bool_env_var("HIDE_TIME_TO_FIRST_RESPONSE", False) + hide_time_to_first_review = get_bool_env_var("HIDE_TIME_TO_FIRST_REVIEW", False) hide_created_at = get_bool_env_var("HIDE_CREATED_AT", True) hide_status = get_bool_env_var("HIDE_STATUS", True) hide_pr_statistics = get_bool_env_var("HIDE_PR_STATISTICS", True) @@ -293,6 +298,7 @@ def get_env_vars(test: bool = False) -> EnvVars: hide_time_to_answer, hide_time_to_close, hide_time_to_first_response, + hide_time_to_first_review, hide_created_at, hide_status, ignore_users_list, diff --git a/issue_metrics.py b/issue_metrics.py index 276f6bd..df0db5e 100755 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -21,6 +21,7 @@ import github3 import github3.structs + from auth import auth_to_github, get_github_app_installation_token from classes import IssueWithMetrics from config import EnvVars, get_env_vars @@ -39,7 +40,10 @@ get_stats_time_to_first_response, measure_time_to_first_response, ) -from time_to_first_review import measure_time_to_first_review +from time_to_first_review import ( + get_stats_time_to_first_review, + measure_time_to_first_review, +) from time_to_merge import measure_time_to_merge from time_to_ready_for_review import get_time_to_ready_for_review @@ -160,7 +164,7 @@ def get_per_issue_metrics( issue_with_metrics.pr_comment_count = count_pr_comments( issue, pull_request, ignore_users ) - if pull_request: + if not env_vars.hide_time_to_first_review and pull_request: issue_with_metrics.time_to_first_review = measure_time_to_first_review( issue, pull_request, @@ -312,6 +316,7 @@ def main(): # pragma: no cover write_to_markdown( issues_with_metrics=None, average_time_to_first_response=None, + average_time_to_first_review=None, average_time_to_close=None, average_time_to_answer=None, average_time_in_draft=None, @@ -340,6 +345,7 @@ def main(): # pragma: no cover write_to_markdown( issues_with_metrics=None, average_time_to_first_response=None, + average_time_to_first_review=None, average_time_to_close=None, average_time_to_answer=None, average_time_in_draft=None, @@ -372,6 +378,7 @@ def main(): # pragma: no cover ) stats_time_to_first_response = get_stats_time_to_first_response(issues_with_metrics) + stats_time_to_first_review = get_stats_time_to_first_review(issues_with_metrics) stats_time_to_close = None if num_issues_closed > 0: stats_time_to_close = get_stats_time_to_close(issues_with_metrics) @@ -392,6 +399,7 @@ def main(): # pragma: no cover write_to_json( issues_with_metrics=issues_with_metrics, stats_time_to_first_response=stats_time_to_first_response, + stats_time_to_first_review=stats_time_to_first_review, stats_time_to_close=stats_time_to_close, stats_time_to_answer=stats_time_to_answer, stats_time_in_draft=stats_time_in_draft, @@ -407,6 +415,7 @@ def main(): # pragma: no cover write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=stats_time_to_first_response, + average_time_to_first_review=stats_time_to_first_review, average_time_to_close=stats_time_to_close, average_time_to_answer=stats_time_to_answer, average_time_in_draft=stats_time_in_draft, diff --git a/json_writer.py b/json_writer.py index 5dcd288..b95b1c6 100644 --- a/json_writer.py +++ b/json_writer.py @@ -4,6 +4,7 @@ write_to_json( issues_with_metrics: Union[List[IssueWithMetrics], None], stats_time_to_first_response: Union[dict[str, timedelta], None], + stats_time_to_first_review: 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], @@ -29,6 +30,7 @@ def write_to_json( issues_with_metrics: Union[List[IssueWithMetrics], None], stats_time_to_first_response: Union[dict[str, timedelta], None], + stats_time_to_first_review: 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], @@ -104,6 +106,15 @@ def write_to_json( med_time_to_first_response = stats_time_to_first_response["med"] p90_time_to_first_response = stats_time_to_first_response["90p"] + # time to first review + average_time_to_first_review = None + med_time_to_first_review = None + p90_time_to_first_review = None + if stats_time_to_first_review is not None: + average_time_to_first_review = stats_time_to_first_review["avg"] + med_time_to_first_review = stats_time_to_first_review["med"] + p90_time_to_first_review = stats_time_to_first_review["90p"] + # time to close average_time_to_close = None med_time_to_close = None @@ -155,6 +166,7 @@ def write_to_json( # Create a dictionary with the metrics metrics: dict[str, Any] = { "average_time_to_first_response": str(average_time_to_first_response), + "average_time_to_first_review": str(average_time_to_first_review), "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), @@ -193,6 +205,7 @@ def write_to_json( "assignee": issue.assignee, "assignees": issue.assignees, "time_to_first_response": str(issue.time_to_first_response), + "time_to_first_review": str(issue.time_to_first_review), "time_to_close": str(issue.time_to_close), "time_to_answer": str(issue.time_to_answer), "time_in_draft": str(issue.time_in_draft), diff --git a/markdown_writer.py b/markdown_writer.py index 4963987..49b0048 100644 --- a/markdown_writer.py +++ b/markdown_writer.py @@ -78,6 +78,10 @@ def get_non_hidden_columns(labels) -> List[str]: if not hide_time_to_first_response: columns.append("Time to first response") + hide_time_to_first_review = env_vars.hide_time_to_first_review + if not hide_time_to_first_review: + columns.append("Time to first review") + hide_time_to_close = env_vars.hide_time_to_close if not hide_time_to_close: columns.append("Time to close") @@ -129,6 +133,7 @@ def sort_issues( valid_fields = { "time_to_close", "time_to_first_response", + "time_to_first_review", "time_to_answer", "time_in_draft", "created_at", @@ -200,6 +205,7 @@ def group_issues( def write_to_markdown( issues_with_metrics: Union[List[IssueWithMetrics], None], average_time_to_first_response: Union[dict[str, timedelta], None], + average_time_to_first_review: Union[dict[str, timedelta], None], average_time_to_close: Union[dict[str, timedelta], None], average_time_to_answer: Union[dict[str, timedelta], None], average_time_in_draft: Union[dict[str, timedelta], None], @@ -268,6 +274,7 @@ def write_to_markdown( write_overall_metrics_tables( issues_with_metrics, average_time_to_first_response, + average_time_to_first_review, average_time_to_close, average_time_to_answer, average_time_in_draft, @@ -345,6 +352,8 @@ def write_to_markdown( ) if "Time to first response" in columns: file.write(f" {issue.time_to_first_response} |") + if "Time to first review" in columns: + file.write(f" {issue.time_to_first_review} |") if "Time to close" in columns: file.write(f" {issue.time_to_close} |") if "Time to answer" in columns: @@ -374,6 +383,7 @@ def write_to_markdown( def write_overall_metrics_tables( issues_with_metrics, stats_time_to_first_response, + stats_time_to_first_review, stats_time_to_close, stats_time_to_answer, average_time_in_draft, @@ -397,6 +407,7 @@ def write_overall_metrics_tables( column in columns for column in [ "Time to first response", + "Time to first review", "Time to close", "Time to answer", "Time in draft", @@ -417,6 +428,16 @@ def write_overall_metrics_tables( ) else: file.write("| Time to first response | None | None | None |\n") + if "Time to first review" in columns: + if stats_time_to_first_review is not None: + file.write( + f"| Time to first review " + f"| {stats_time_to_first_review['avg']} " + f"| {stats_time_to_first_review['med']} " + f"| {stats_time_to_first_review['90p']} |\n" + ) + else: + file.write("| Time to first review | None | None | None |\n") if "Time to close" in columns: if stats_time_to_close is not None: file.write( diff --git a/time_to_first_review.py b/time_to_first_review.py index a8c96ac..f3d8e87 100644 --- a/time_to_first_review.py +++ b/time_to_first_review.py @@ -5,6 +5,7 @@ import github3 import numpy + from classes import IssueWithMetrics from time_to_first_response import ignore_comment @@ -40,7 +41,10 @@ def measure_time_to_first_review( first_review_time = review.submitted_at break - except TypeError: + except TypeError as e: + print( + f"An error occurred processing review comments. Perhaps the review contains a ghost user. {e}" + ) return None if first_review_time is None: From 7317684a8f8802254f5d30687af3fafce7097cde Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Tue, 10 Mar 2026 05:29:51 +0000 Subject: [PATCH 8/9] feat: add time to first review metric and update tests likewise --- json_writer.py | 2 ++ test_assignee_integration.py | 2 ++ test_column_order_fix.py | 5 +++-- test_config.py | 4 ++++ test_json_writer.py | 12 ++++++++++++ test_markdown_writer.py | 27 +++++++++++++++++++-------- test_sorting_grouping.py | 2 ++ 7 files changed, 44 insertions(+), 10 deletions(-) diff --git a/json_writer.py b/json_writer.py index b95b1c6..67834f9 100644 --- a/json_writer.py +++ b/json_writer.py @@ -172,11 +172,13 @@ def write_to_json( "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_first_review": str(med_time_to_first_review), "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_first_review": str(p90_time_to_first_review), "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), diff --git a/test_assignee_integration.py b/test_assignee_integration.py index 1af28e6..ab7f3ff 100644 --- a/test_assignee_integration.py +++ b/test_assignee_integration.py @@ -54,6 +54,7 @@ def test_assignee_in_markdown_output(self): try: write_to_markdown( issues_with_metrics=issues_with_metrics, + average_time_to_first_review=None, average_time_to_first_response={ "avg": timedelta(hours=3), "med": timedelta(hours=3), @@ -132,6 +133,7 @@ def test_assignee_in_json_output(self): try: json_output = write_to_json( issues_with_metrics=issues_with_metrics, + stats_time_to_first_review=None, stats_time_to_first_response={ "avg": timedelta(hours=3), "med": timedelta(hours=3), diff --git a/test_column_order_fix.py b/test_column_order_fix.py index 45fcfc6..54418b8 100644 --- a/test_column_order_fix.py +++ b/test_column_order_fix.py @@ -55,6 +55,7 @@ def test_status_and_created_at_columns_alignment(self): write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=None, + average_time_to_first_review=None, average_time_to_close=None, average_time_to_answer=None, average_time_in_draft=None, @@ -80,7 +81,7 @@ def test_status_and_created_at_columns_alignment(self): # The table should have the columns in the correct order # and the data should be properly aligned expected_header = ( - "| Title | URL | Assignee | Author | Time to first response | " + "| Title | URL | Assignee | Author | Time to first response | Time to first review | " "Time to close | Time to answer | Created At | Status |" ) self.assertIn(expected_header, content) @@ -92,7 +93,7 @@ def test_status_and_created_at_columns_alignment(self): "| Test Issue | https://github.com/user/repo/issues/1 | " "[assignee1](https://github.com/assignee1) | " "[testuser](https://github.com/testuser) | 1 day, 0:00:00 | " - "2 days, 0:00:00 | 3 days, 0:00:00 | 2023-01-01T00:00:00Z | open |" + "None | 2 days, 0:00:00 | 3 days, 0:00:00 | 2023-01-01T00:00:00Z | open |" ) self.assertIn(expected_row, content) diff --git a/test_config.py b/test_config.py index 280d588..bbb84e6 100644 --- a/test_config.py +++ b/test_config.py @@ -131,6 +131,7 @@ def test_get_env_vars_with_github_app(self): hide_time_to_answer=False, hide_time_to_close=False, hide_time_to_first_response=False, + hide_time_to_first_review=False, hide_created_at=True, hide_status=True, ignore_user=[], @@ -187,6 +188,7 @@ def test_get_env_vars_with_token(self): hide_time_to_answer=False, hide_time_to_close=False, hide_time_to_first_response=False, + hide_time_to_first_review=False, hide_created_at=True, hide_status=True, ignore_user=[], @@ -292,6 +294,7 @@ def test_get_env_vars_optional_values(self): hide_time_to_answer=True, hide_time_to_close=True, hide_time_to_first_response=True, + hide_time_to_first_review=False, hide_created_at=True, hide_status=True, ignore_user=[], @@ -339,6 +342,7 @@ def test_get_env_vars_optionals_are_defaulted(self): hide_time_to_answer=False, hide_time_to_close=False, hide_time_to_first_response=False, + hide_time_to_first_review=False, hide_created_at=True, hide_status=True, ignore_user=[], diff --git a/test_json_writer.py b/test_json_writer.py index 3a6a24f..5924316 100644 --- a/test_json_writer.py +++ b/test_json_writer.py @@ -77,16 +77,19 @@ def test_write_to_json(self): expected_output = { "average_time_to_first_response": "2 days, 12:00:00", + "average_time_to_first_review": "None", "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_first_review": "None", "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_first_review": "None", "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", @@ -106,6 +109,7 @@ def test_write_to_json(self): "assignee": "charlie", "assignees": ["charlie"], "time_to_first_response": "3 days, 0:00:00", + "time_to_first_review": "None", "time_to_close": "6 days, 0:00:00", "time_to_answer": "None", "time_in_draft": "1 day, 0:00:00", @@ -120,6 +124,7 @@ def test_write_to_json(self): "assignee": None, "assignees": [], "time_to_first_response": "2 days, 0:00:00", + "time_to_first_review": "None", "time_to_close": "4 days, 0:00:00", "time_to_answer": "1 day, 0:00:00", "time_in_draft": "None", @@ -136,6 +141,7 @@ def test_write_to_json(self): write_to_json( issues_with_metrics=issues_with_metrics, stats_time_to_first_response=stats_time_to_first_response, + stats_time_to_first_review=None, stats_time_to_close=stats_time_to_close, stats_time_to_answer=stats_time_to_answer, stats_time_in_draft=stats_time_in_draft, @@ -194,16 +200,19 @@ def test_write_to_json_with_no_response(self): expected_output = { "average_time_to_first_response": "None", + "average_time_to_first_review": "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_first_review": "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_first_review": "None", "90_percentile_time_to_close": "None", "90_percentile_time_to_answer": "None", "90_percentile_time_in_draft": "None", @@ -223,6 +232,7 @@ def test_write_to_json_with_no_response(self): "assignee": None, "assignees": [], "time_to_first_response": "None", + "time_to_first_review": "None", "time_to_close": "None", "time_to_answer": "None", "time_in_draft": "None", @@ -237,6 +247,7 @@ def test_write_to_json_with_no_response(self): "assignee": None, "assignees": [], "time_to_first_response": "None", + "time_to_first_review": "None", "time_to_close": "None", "time_to_answer": "None", "time_in_draft": "None", @@ -253,6 +264,7 @@ def test_write_to_json_with_no_response(self): write_to_json( issues_with_metrics=issues_with_metrics, stats_time_to_first_response=stats_time_to_first_response, + stats_time_to_first_review=None, stats_time_to_close=stats_time_to_close, stats_time_to_answer=stats_time_to_answer, stats_time_in_draft=stats_time_in_draft, diff --git a/test_markdown_writer.py b/test_markdown_writer.py index 29129f2..46b199f 100644 --- a/test_markdown_writer.py +++ b/test_markdown_writer.py @@ -103,6 +103,7 @@ def test_write_to_markdown(self): write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=time_to_first_response, + average_time_to_first_review=None, average_time_to_close=time_to_close, average_time_to_answer=time_to_answer, average_time_in_draft=time_in_draft, @@ -126,6 +127,7 @@ def test_write_to_markdown(self): "| Metric | Average | Median | 90th percentile |\n" "| --- | --- | --- | ---: |\n" "| Time to first response | 2 days, 0:00:00 | 2 days, 0:00:00 | 2 days, 0:00:00 |\n" + "| Time to first review | None | None | None |\n" "| Time to close | 3 days, 0:00:00 | 3 days, 0:00:00 | 3 days, 0:00:00 |\n" "| Time to answer | 4 days, 0:00:00 | 4 days, 0:00:00 | 4 days, 0:00:00 |\n" "| Time in draft | 1 day, 0:00:00 | 1 day, 0:00:00 | 1 day, 0:00:00 |\n" @@ -137,13 +139,13 @@ def test_write_to_markdown(self): "| Number of items that remain open | 2 |\n" "| Number of items closed | 1 |\n" "| Total number of items created | 2 |\n\n" - "| Title | URL | Assignee | Author | Time to first response | Time to close | " + "| Title | URL | Assignee | Author | Time to first response | Time to first review | Time to close | " "Time to answer | Time in draft | Time spent in bug | Created At | Status |\n" - "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n" + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n" "| Issue 1 | https://github.com/user/repo/issues/1 | [charlie](https://github.com/charlie) | " - "[alice](https://github.com/alice) | 1 day, 0:00:00 | 2 days, 0:00:00 | 3 days, 0:00:00 | " + "[alice](https://github.com/alice) | 1 day, 0:00:00 | None | 2 days, 0:00:00 | 3 days, 0:00:00 | " "1 day, 0:00:00 | 4 days, 0:00:00 | -5 days, 0:00:00 | None |\n" - "| Issue 2 | https://github.com/user/repo/issues/2 | None | [bob](https://github.com/bob) | 3 days, 0:00:00 | " + "| Issue 2 | https://github.com/user/repo/issues/2 | None | [bob](https://github.com/bob) | 3 days, 0:00:00 | None | " "4 days, 0:00:00 | 5 days, 0:00:00 | 1 day, 0:00:00 | 2 days, 0:00:00 | -5 days, 0:00:00 | None |\n\n" "_This report was generated with the [Issue Metrics Action](https://github.com/github-community-projects/issue-metrics)_\n" "Search query used to find these items: `is:issue is:open label:bug`\n" @@ -223,6 +225,7 @@ def test_write_to_markdown_with_vertical_bar_in_title(self): write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=average_time_to_first_response, + average_time_to_first_review=None, average_time_to_close=average_time_to_close, average_time_to_answer=average_time_to_answer, average_time_in_draft=average_time_in_draft, @@ -244,6 +247,7 @@ def test_write_to_markdown_with_vertical_bar_in_title(self): "| Metric | Average | Median | 90th percentile |\n" "| --- | --- | --- | ---: |\n" "| Time to first response | 2 days, 0:00:00 | 2 days, 0:00:00 | 2 days, 0:00:00 |\n" + "| Time to first review | None | None | None |\n" "| Time to close | 3 days, 0:00:00 | 3 days, 0:00:00 | 3 days, 0:00:00 |\n" "| Time to answer | 4 days, 0:00:00 | 4 days, 0:00:00 | 4 days, 0:00:00 |\n" "| Time in draft | 1 day, 0:00:00 | 1 day, 0:00:00 | 1 day, 0:00:00 |\n" @@ -255,14 +259,14 @@ def test_write_to_markdown_with_vertical_bar_in_title(self): "| Number of items that remain open | 2 |\n" "| Number of items closed | 1 |\n" "| Total number of items created | 2 |\n\n" - "| Title | URL | Assignee | Author | Time to first response | Time to close | " + "| Title | URL | Assignee | Author | Time to first response | Time to first review | Time to close | " "Time to answer | Time in draft | Time spent in bug | Created At | Status |\n" - "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n" + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n" "| Issue 1 | https://github.com/user/repo/issues/1 | [charlie](https://github.com/charlie) | " - "[alice](https://github.com/alice) | 1 day, 0:00:00 | 2 days, 0:00:00 | 3 days, 0:00:00 | " + "[alice](https://github.com/alice) | 1 day, 0:00:00 | None | 2 days, 0:00:00 | 3 days, 0:00:00 | " "1 day, 0:00:00 | 1 day, 0:00:00 | -5 days, 0:00:00 | None |\n" "| feat| Issue 2 | https://github.com/user/repo/issues/2 | None | " - "[bob](https://github.com/bob) | 3 days, 0:00:00 | " + "[bob](https://github.com/bob) | 3 days, 0:00:00 | None | " "4 days, 0:00:00 | 5 days, 0:00:00 | None | 2 days, 0:00:00 | -5 days, 0:00:00 | None |\n\n" "_This report was generated with the [Issue Metrics Action](https://github.com/github-community-projects/issue-metrics)_\n" ) @@ -284,6 +288,7 @@ def test_write_to_markdown_no_issues(self): None, None, None, + None, report_title="Issue Metrics", ) @@ -310,6 +315,7 @@ def test_write_to_markdown_no_issues(self): "GH_TOKEN": "test_token", "HIDE_CREATED_AT": "False", "HIDE_TIME_TO_FIRST_RESPONSE": "True", + "HIDE_TIME_TO_FIRST_REVIEW": "True", "HIDE_TIME_TO_CLOSE": "True", "HIDE_TIME_TO_ANSWER": "True", "HIDE_LABEL_METRICS": "True", @@ -379,6 +385,7 @@ def test_writes_markdown_file_with_non_hidden_columns_only(self): write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=average_time_to_first_response, + average_time_to_first_review=None, average_time_to_close=average_time_to_close, average_time_to_answer=average_time_to_answer, average_time_in_draft=average_time_in_draft, @@ -428,6 +435,7 @@ def test_writes_markdown_file_with_non_hidden_columns_only(self): "GH_TOKEN": "test_token", "HIDE_CREATED_AT": "False", "HIDE_TIME_TO_FIRST_RESPONSE": "True", + "HIDE_TIME_TO_FIRST_REVIEW": "True", "HIDE_TIME_TO_CLOSE": "True", "HIDE_TIME_TO_ANSWER": "True", "HIDE_LABEL_METRICS": "True", @@ -490,6 +498,7 @@ def test_writes_markdown_file_with_hidden_status_column(self): write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=average_time_to_first_response, + average_time_to_first_review=None, average_time_to_close=average_time_to_close, average_time_to_answer=average_time_to_answer, average_time_in_draft=average_time_in_draft, @@ -538,6 +547,7 @@ def test_writes_markdown_file_with_hidden_status_column(self): "GH_TOKEN": "test_token", "HIDE_CREATED_AT": "False", "HIDE_TIME_TO_FIRST_RESPONSE": "True", + "HIDE_TIME_TO_FIRST_REVIEW": "True", "HIDE_TIME_TO_CLOSE": "True", "HIDE_TIME_TO_ANSWER": "True", "HIDE_LABEL_METRICS": "True", @@ -601,6 +611,7 @@ def test_writes_markdown_file_with_hidden_items_list(self): write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=average_time_to_first_response, + average_time_to_first_review=None, average_time_to_close=average_time_to_close, average_time_to_answer=average_time_to_answer, average_time_in_draft=average_time_in_draft, diff --git a/test_sorting_grouping.py b/test_sorting_grouping.py index a080750..d50d437 100644 --- a/test_sorting_grouping.py +++ b/test_sorting_grouping.py @@ -297,6 +297,7 @@ def test_write_to_markdown_with_sorting(self): write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=None, + average_time_to_first_review=None, average_time_to_close=None, average_time_to_answer=None, average_time_in_draft=None, @@ -357,6 +358,7 @@ def test_write_to_markdown_with_grouping(self): write_to_markdown( issues_with_metrics=issues_with_metrics, average_time_to_first_response=None, + average_time_to_first_review=None, average_time_to_close=None, average_time_to_answer=None, average_time_in_draft=None, From 319ca220e4cb2485dd73ece4c329f963a48cb8b9 Mon Sep 17 00:00:00 2001 From: Aayushi <160361557+meoyushi@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:08:56 +0000 Subject: [PATCH 9/9] fix: style and lint fix --- issue_metrics.py | 1 - time_to_first_review.py | 1 - 2 files changed, 2 deletions(-) diff --git a/issue_metrics.py b/issue_metrics.py index df0db5e..5a45815 100755 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -21,7 +21,6 @@ import github3 import github3.structs - from auth import auth_to_github, get_github_app_installation_token from classes import IssueWithMetrics from config import EnvVars, get_env_vars diff --git a/time_to_first_review.py b/time_to_first_review.py index f3d8e87..6fec968 100644 --- a/time_to_first_review.py +++ b/time_to_first_review.py @@ -5,7 +5,6 @@ import github3 import numpy - from classes import IssueWithMetrics from time_to_first_response import ignore_comment