Skip to content

Commit bcdaf83

Browse files
authored
Merge pull request #111 from PressXtoChris/main
2 parents 8889037 + ec68ee1 commit bcdaf83

8 files changed

+277
-25
lines changed

README.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ several metrics. The issues/pull requests/discussions to search for can be filte
88
The metrics that are measured are:
99
| Metric | Description |
1010
|--------|-------------|
11-
| Time to first response | The time between when an issue/pull request/discussion is created and when the first comment or review is made. |
12-
| Time to close | The time between when an issue/pull request/discussion is created and when it is closed. |
11+
| Time to first response | The time between when an issue/pull request/discussion is created and when the first comment or review is made.* |
12+
| Time to close | The time between when an issue/pull request/discussion is created and when it is closed.* |
1313
| Time to answer | (Discussions only) The time between when a discussion is created and when it is answered. |
1414
| Time in label | The time between when a label has a specific label applied to an issue/pull request/discussion and when it is removed. This requires the LABELS_TO_MEASURE env variable to be set. |
1515

16+
*For pull requests, these metrics exclude the time the PR was in draft mode.
17+
1618
This action was developed by the GitHub OSPO for our own use and developed in a way that we could open source it that it might be useful to you as well! If you want to know more about how we use it, reach out in an issue in this repository.
1719

1820
To find syntax for search queries, check out the [documentation on searching issues and pull requests](https://docs.github.com/en/issues/tracking-your-work-with-issues/filtering-and-searching-issues-and-pull-requests) or the [documentation on searching discussions](https://docs.github.com/en/search-github/searching-on-github/searching-discussions).

issue_metrics.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from markdown_writer import write_to_markdown
3737
from time_to_answer import get_average_time_to_answer, measure_time_to_answer
3838
from time_to_close import get_average_time_to_close, measure_time_to_close
39+
from time_to_ready_for_review import get_time_to_ready_for_review
40+
from time_to_merge import measure_time_to_merge
3941
from time_to_first_response import (
4042
get_average_time_to_first_response,
4143
measure_time_to_first_response,
@@ -193,13 +195,23 @@ def get_per_issue_metrics(
193195
None,
194196
None,
195197
)
198+
199+
# Check if issue is actually a pull request
200+
pull_request, ready_for_review_at = None, None
201+
if issue.issue.pull_request_urls:
202+
pull_request = issue.issue.pull_request()
203+
ready_for_review_at = get_time_to_ready_for_review(issue, pull_request)
204+
196205
issue_with_metrics.time_to_first_response = measure_time_to_first_response(
197-
issue, None, ignore_users
206+
issue, None, pull_request, ready_for_review_at, ignore_users
198207
)
199208
if labels:
200209
issue_with_metrics.label_metrics = get_label_metrics(issue, labels)
201210
if issue.state == "closed": # type: ignore
202-
issue_with_metrics.time_to_close = measure_time_to_close(issue, None)
211+
if pull_request:
212+
issue_with_metrics.time_to_close = measure_time_to_merge(pull_request, ready_for_review_at)
213+
else:
214+
issue_with_metrics.time_to_close = measure_time_to_close(issue, None)
203215
num_issues_closed += 1
204216
elif issue.state == "open": # type: ignore
205217
num_issues_open += 1

test_time_to_first_response.py

+50-17
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,17 @@ def test_measure_time_to_first_response_with_pull_request_comments(self):
7474
mock_issue1.comments = 2
7575
mock_issue1.issue.user.login = "issue_owner"
7676
mock_issue1.created_at = "2023-01-01T00:00:00Z"
77-
mock_issue1.pull_request_urls = {"url": "https://api.github.com/repos/owner/repo/pulls/1"}
7877

7978
# Set up the mock GitHub pull request comments
8079
mock_pr_comment1 = MagicMock()
8180
mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-02T00:00:00Z") # first response
8281
mock_pr_comment2 = MagicMock()
8382
mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-02T12:00:00Z")
84-
mock_issue1.issue.pull_request().reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
83+
mock_pull_request = MagicMock()
84+
mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
8585

8686
# Call the function
87-
result = measure_time_to_first_response(mock_issue1, None)
87+
result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None)
8888
expected_result = timedelta(days=1)
8989

9090
# Check the results
@@ -98,7 +98,6 @@ def test_measure_time_to_first_response_issue_comment_faster(self):
9898
mock_issue1.comments = 2
9999
mock_issue1.issue.user.login = "issue_owner"
100100
mock_issue1.created_at = "2023-01-01T00:00:00Z"
101-
mock_issue1.pull_request_urls = {"url": "https://api.github.com/repos/owner/repo/pulls/1"}
102101

103102
# Set up the mock GitHub issue comment
104103
mock_comment1 = MagicMock()
@@ -108,10 +107,11 @@ def test_measure_time_to_first_response_issue_comment_faster(self):
108107
# Set up the mock GitHub pull request comment
109108
mock_pr_comment1 = MagicMock()
110109
mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-03T00:00:00Z")
111-
mock_issue1.issue.pull_request().reviews.return_value = [mock_pr_comment1]
110+
mock_pull_request = MagicMock()
111+
mock_pull_request.reviews.return_value = [mock_pr_comment1]
112112

113113
# Call the function
114-
result = measure_time_to_first_response(mock_issue1, None)
114+
result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None)
115115
expected_result = timedelta(days=1)
116116

117117
# Check the results
@@ -125,7 +125,6 @@ def test_measure_time_to_first_response_pull_request_comment_faster(self):
125125
mock_issue1.comments = 2
126126
mock_issue1.issue.user.login = "issue_owner"
127127
mock_issue1.created_at = "2023-01-01T00:00:00Z"
128-
mock_issue1.pull_request_urls = {"url": "https://api.github.com/repos/owner/repo/pulls/1"}
129128

130129
# Set up the mock GitHub pull issue comment
131130
mock_comment1 = MagicMock()
@@ -135,10 +134,43 @@ def test_measure_time_to_first_response_pull_request_comment_faster(self):
135134
# Set up the mock GitHub pull request comment
136135
mock_pr_comment1 = MagicMock()
137136
mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-02T00:00:00Z") # first response
138-
mock_issue1.issue.pull_request().reviews.return_value = [mock_pr_comment1]
137+
mock_pull_request = MagicMock()
138+
mock_pull_request.reviews.return_value = [mock_pr_comment1]
139139

140140
# Call the function
141-
result = measure_time_to_first_response(mock_issue1, None)
141+
result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None)
142+
expected_result = timedelta(days=1)
143+
144+
# Check the results
145+
self.assertEqual(result, expected_result)
146+
147+
def test_measure_time_to_first_response_pull_request_comment_ignore_before_ready(self):
148+
"""Test that measure_time_to_first_response ignores comments from before the pull request was ready for review."""
149+
# Set up the mock GitHub issues
150+
mock_issue1 = MagicMock()
151+
mock_issue1.comments = 4
152+
mock_issue1.issue.user.login = "issue_owner"
153+
mock_issue1.created_at = "2023-01-01T00:00:00Z"
154+
155+
# Set up the mock GitHub issue comments (one ignored, one not ignored)
156+
mock_comment1 = MagicMock()
157+
mock_comment1.created_at = datetime.fromisoformat("2023-01-02T00:00:00Z")
158+
mock_comment2 = MagicMock()
159+
mock_comment2.created_at = datetime.fromisoformat("2023-01-05T00:00:00Z")
160+
mock_issue1.issue.comments.return_value = [mock_comment1, mock_comment2]
161+
162+
# Set up the mock GitHub pull request comments (one ignored, one not ignored)
163+
mock_pr_comment1 = MagicMock()
164+
mock_pr_comment1.submitted_at = datetime.fromisoformat("2023-01-02T12:00:00Z")
165+
mock_pr_comment2 = MagicMock()
166+
mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-04T00:00:00Z") # first response
167+
mock_pull_request = MagicMock()
168+
mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
169+
170+
ready_for_review_at = datetime.fromisoformat("2023-01-03T00:00:00Z")
171+
172+
# Call the function
173+
result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, ready_for_review_at)
142174
expected_result = timedelta(days=1)
143175

144176
# Check the results
@@ -168,10 +200,11 @@ def test_measure_time_to_first_response_ignore_users(self):
168200
mock_pr_comment2 = MagicMock()
169201
mock_pr_comment2.user.login = "not_ignored_user"
170202
mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-04T00:00:00Z") # first response
171-
mock_issue1.issue.pull_request().reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
203+
mock_pull_request = MagicMock()
204+
mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
172205

173206
# Call the function
174-
result = measure_time_to_first_response(mock_issue1, None, ["ignored_user"])
207+
result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None, ["ignored_user"])
175208
expected_result = timedelta(days=3)
176209

177210
# Check the results
@@ -184,7 +217,6 @@ def test_measure_time_to_first_response_only_ignored_users(self):
184217
mock_issue1.comments = 4
185218
mock_issue1.issue.user.login = "issue_owner"
186219
mock_issue1.created_at = "2023-01-01T00:00:00Z"
187-
mock_issue1.pull_request_urls = {"url": "https://api.github.com/repos/owner/repo/pulls/1"}
188220

189221
# Set up the mock GitHub issue comments (all ignored)
190222
mock_comment1 = MagicMock()
@@ -202,11 +234,12 @@ def test_measure_time_to_first_response_only_ignored_users(self):
202234
mock_pr_comment2 = MagicMock()
203235
mock_pr_comment2.user.login = "ignored_user2"
204236
mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-04T12:00:00Z")
205-
mock_issue1.issue.pull_request().reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
237+
mock_pull_request = MagicMock()
238+
mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
206239

207240
# Call the function
208241
result = measure_time_to_first_response(
209-
mock_issue1, None, ["ignored_user", "ignored_user2"]
242+
mock_issue1, None, mock_pull_request, None, ["ignored_user", "ignored_user2"]
210243
)
211244
expected_result = None
212245

@@ -220,7 +253,6 @@ def test_measure_time_to_first_response_ignore_issue_owners_comment(self):
220253
mock_issue1.comments = 4
221254
mock_issue1.issue.user.login = "issue_owner"
222255
mock_issue1.created_at = "2023-01-01T00:00:00Z"
223-
mock_issue1.pull_request_urls = {"url": "https://api.github.com/repos/owner/repo/pulls/1"}
224256

225257
# Set up the mock GitHub issue comments
226258
mock_comment1 = MagicMock()
@@ -238,10 +270,11 @@ def test_measure_time_to_first_response_ignore_issue_owners_comment(self):
238270
mock_pr_comment2 = MagicMock()
239271
mock_pr_comment2.user.login = "other_user"
240272
mock_pr_comment2.submitted_at = datetime.fromisoformat("2023-01-04T00:00:00Z")
241-
mock_issue1.issue.pull_request().reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
273+
mock_pull_request = MagicMock()
274+
mock_pull_request.reviews.return_value = [mock_pr_comment1, mock_pr_comment2]
242275

243276
# Call the function
244-
result = measure_time_to_first_response(mock_issue1, None)
277+
result = measure_time_to_first_response(mock_issue1, None, mock_pull_request, None)
245278
expected_result = timedelta(days=3)
246279

247280
# Check the results

test_time_to_merge.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""A module containing unit tests for the time_to_merge module.
2+
3+
This module contains unit tests for the measure_time_to_merge
4+
function in the time_to_merge module.
5+
The tests use mock GitHub pull request to test the function's behavior.
6+
7+
Classes:
8+
TestMeasureTimeToMerge: A class to test the measure_time_to_merge function.
9+
10+
"""
11+
from datetime import timedelta, datetime
12+
import unittest
13+
from unittest.mock import MagicMock
14+
15+
from time_to_merge import measure_time_to_merge
16+
17+
class TestMeasureTimeToMerge(unittest.TestCase):
18+
"""Test suite for the measure_time_to_merge function."""
19+
20+
def test_measure_time_to_merge_ready_for_review(self):
21+
"""Test that the function correctly measures the time to merge a pull request that was formerly a draft."""
22+
# Create a mock pull request object
23+
pull_request = MagicMock()
24+
pull_request.merged_at = datetime.fromisoformat("2021-01-03T00:00:00Z")
25+
ready_for_review_at = datetime.fromisoformat("2021-01-01T00:00:00Z")
26+
27+
# Call the function and check the result
28+
result = measure_time_to_merge(pull_request, ready_for_review_at)
29+
expected_result = timedelta(days=2)
30+
self.assertEqual(result, expected_result)
31+
32+
def test_measure_time_to_merge_created_at(self):
33+
"""Test that the function correctly measures the time to merge a pull request that was never a draft."""
34+
# Create a mock pull request object
35+
pull_request = MagicMock()
36+
pull_request.merged_at = datetime.fromisoformat("2021-01-03T00:00:00Z")
37+
pull_request.created_at = datetime.fromisoformat("2021-01-01T00:00:00Z")
38+
39+
# Call the function and check the result
40+
result = measure_time_to_merge(pull_request, None)
41+
expected_result = timedelta(days=2)
42+
self.assertEqual(result, expected_result)
43+
44+
def test_measure_time_to_merge_returns_none(self):
45+
"""Test that the function returns None if the pull request is not merged."""
46+
# Create a mock issue object
47+
pull_request = MagicMock()
48+
pull_request.merged_at = None
49+
50+
# Call the function and check that it returns None
51+
self.assertEqual(None, measure_time_to_merge(pull_request, None))

test_time_to_ready_for_review.py

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""A module containing unit tests for the time_to_ready_for_review module.
2+
3+
This module contains unit tests for the get_time_to_ready_for_review
4+
function in the time_to_ready_for_review module.
5+
The tests use mock GitHub issues to test the functions' behavior.
6+
7+
Classes:
8+
TestGetTimeToReadyForReview: A class to test the get_time_to_ready_for_review function.
9+
10+
"""
11+
from datetime import datetime
12+
import unittest
13+
from unittest.mock import MagicMock
14+
15+
from time_to_ready_for_review import get_time_to_ready_for_review
16+
17+
class TestGetTimeToReadyForReview(unittest.TestCase):
18+
"""Test suite for the get_time_to_ready_for_review function."""
19+
20+
# def draft pr function
21+
def test_time_to_ready_for_review_draft(self):
22+
"""Test that the function returns None when the pull request is a draft"""
23+
pull_request = MagicMock()
24+
pull_request.draft = True
25+
issue = MagicMock()
26+
27+
result = get_time_to_ready_for_review(issue, pull_request)
28+
expected_result = None
29+
self.assertEqual(result, expected_result)
30+
31+
def test_get_time_to_ready_for_review_event(self):
32+
"""Test that the function correctly gets the time a pull request was marked as ready for review"""
33+
pull_request = MagicMock()
34+
pull_request.draft = False
35+
event = MagicMock()
36+
event.event = 'ready_for_review'
37+
event.created_at = datetime.fromisoformat("2021-01-01T00:00:00Z")
38+
issue = MagicMock()
39+
issue.issue.events.return_value=[event]
40+
41+
result = get_time_to_ready_for_review(issue, pull_request)
42+
expected_result = event.created_at
43+
self.assertEqual(result, expected_result)
44+
45+
def test_get_time_to_ready_for_review_no_event(self):
46+
"""Test that the function returns None when the pull request is not a draft and no ready_for_review event is found"""
47+
pull_request = MagicMock()
48+
pull_request.draft = False
49+
event = MagicMock()
50+
event.event = 'foobar'
51+
event.created_at = "2021-01-01T00:00:00Z"
52+
issue = MagicMock()
53+
issue.events.return_value=[event]
54+
55+
result = get_time_to_ready_for_review(issue, pull_request)
56+
expected_result = None
57+
self.assertEqual(result, expected_result)

time_to_first_response.py

+14-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
measure_time_to_first_response(
99
issue: Union[github3.issues.Issue, None],
1010
discussion: Union[dict, None]
11+
pull_request: Union[github3.pulls.PullRequest, None],
1112
) -> Union[timedelta, None]:
1213
Measure the time to first response for a single issue or a discussion.
1314
get_average_time_to_first_response(
@@ -27,13 +28,16 @@
2728
def measure_time_to_first_response(
2829
issue: Union[github3.issues.Issue, None], # type: ignore
2930
discussion: Union[dict, None],
31+
pull_request: Union[github3.pulls.PullRequest, None] = None,
32+
ready_for_review_at: Union[datetime, None] = None,
3033
ignore_users: List[str] = None,
3134
) -> Union[timedelta, None]:
32-
"""Measure the time to first response for a single issue or a discussion.
35+
"""Measure the time to first response for a single issue, pull request, or a discussion.
3336
3437
Args:
3538
issue (Union[github3.issues.Issue, None]): A GitHub issue.
3639
discussion (Union[dict, None]): A GitHub discussion.
40+
pull_request (Union[github3.pulls.PullRequest, None]): A GitHub pull request.
3741
ignore_users (List[str]): A list of GitHub usernames to ignore.
3842
3943
Returns:
@@ -57,19 +61,22 @@ def measure_time_to_first_response(
5761
continue
5862
if comment.user.login == issue.issue.user.login:
5963
continue
64+
if ready_for_review_at and comment.created_at < ready_for_review_at:
65+
continue
6066
first_comment_time = comment.created_at
6167
break
6268

6369
# Check if the issue is actually a pull request
6470
# so we may also get the first review comment time
65-
if issue.issue.pull_request_urls:
66-
pull_request = issue.issue.pull_request()
71+
if pull_request:
6772
review_comments = pull_request.reviews(number=50) # type: ignore
6873
for review_comment in review_comments:
6974
if review_comment.user.login in ignore_users:
7075
continue
7176
if review_comment.user.login == issue.issue.user.login:
7277
continue
78+
if ready_for_review_at and review_comment.submitted_at < ready_for_review_at:
79+
continue
7380
first_review_comment_time = review_comment.submitted_at
7481
break
7582

@@ -84,7 +91,10 @@ def measure_time_to_first_response(
8491
return None
8592

8693
# Get the created_at time for the issue so we can calculate the time to first response
87-
issue_time = datetime.fromisoformat(issue.created_at) # type: ignore
94+
if ready_for_review_at:
95+
issue_time = ready_for_review_at
96+
else:
97+
issue_time = datetime.fromisoformat(issue.created_at) # type: ignore
8898

8999
if discussion and len(discussion["comments"]["nodes"]) > 0:
90100
earliest_response = datetime.fromisoformat(

0 commit comments

Comments
 (0)