Skip to content

Commit 69c1c46

Browse files
authored
Merge pull request #45 from github/labels
feat: add time in labels
2 parents 7fbd86b + 3ecf559 commit 69c1c46

13 files changed

+514
-53
lines changed

.env-example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
GH_TOKEN = " "
22
SEARCH_QUERY = "repo:owner/repo is:open is:issue"
3+
LABELS_TO_MEASURE = "waiting-for-review,waiting-for-manager"
34
HIDE_TIME_TO_FIRST_RESPONSE = False
45
HIDE_TIME_TO_CLOSE = False
56
HIDE_TIME_TO_ANSWER = False
7+
HIDE_LABEL_METRICS = False

.pylintrc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ disable=
33
redefined-argument-from-local,
44
too-many-arguments,
55
too-few-public-methods,
6-
duplicate-code,
6+
duplicate-code,
7+
too-many-locals,
8+
too-many-branches,

README.md

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22

33
[![CodeQL](https://github.com/github/issue-metrics/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/codeql-analysis.yml) [![Docker Image CI](https://github.com/github/issue-metrics/actions/workflows/docker-image.yml/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/docker-image.yml) [![Python package](https://github.com/github/issue-metrics/actions/workflows/python-package.yml/badge.svg)](https://github.com/github/issue-metrics/actions/workflows/python-package.yml)
44

5-
This is a GitHub Action that searches for pull requests/issues/discussions in a repository and measures
6-
the time to first response for each one. It then calculates the average time
7-
to first response and writes the issues/pull requests/discussions with their metrics
8-
to a Markdown file. The issues/pull requests/discussions to search for can be filtered by using a search query.
5+
This is a GitHub Action that searches for pull requests/issues/discussions in a repository and measures and reports on
6+
several metrics. The issues/pull requests/discussions to search for can be filtered by using a search query.
7+
8+
The metrics that are measured are:
9+
| Metric | Description |
10+
|--------|-------------|
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. |
13+
| Time to answer | (Discussions only) The time between when a discussion is created and when it is answered. |
14+
| Time in label | The time between when a label has a specific label appplied to an issue/pull request/discussion and when it is removed. This requires the LABELS_TO_MEASURE env variable to be set. |
915

1016
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.
1117

@@ -37,9 +43,11 @@ Below are the allowed configuration options:
3743
|-----------------------|----------|---------|-------------|
3844
| `GH_TOKEN` | True | | The GitHub Token used to scan the repository. Must have read access to all repository you are interested in scanning. |
3945
| `SEARCH_QUERY` | True | | The query by which you can filter issues/prs which must contain a `repo:` entry or an `org:` entry. For discussions, include `type:discussions` in the query. |
46+
| `LABELS_TO_MEASURE` | False | | A comma separated list of labels to measure how much time the label is applied. If not provided, no labels durations will be measured. Not compatible with discussions at this time. |
4047
| `HIDE_TIME_TO_FIRST_RESPONSE` | False | False | If set to true, the time to first response will not be displayed in the generated markdown file. |
4148
| `HIDE_TIME_TO_CLOSE` | False | False | If set to true, the time to close will not be displayed in the generated markdown file. |
4249
| `HIDE_TIME_TO_ANSWER` | False | False | If set to true, the time to answer a discussion will not be displayed in the generated markdown file. |
50+
| `HIDE_LABEL_METRICS` | False | False | If set to true, the time in label metrics will not be displayed in the generated markdown file. |
4351

4452
### Example workflows
4553

@@ -197,6 +205,65 @@ jobs:
197205
assignees: <YOUR_GITHUB_HANDLE_HERE>
198206
```
199207
208+
## Measuring time spent in labels
209+
210+
**Note**: The discussions API currently doesn't support the `LabeledEvent` so this action cannot measure the time spent in a label for discussions.
211+
212+
Sometimes it is helpful to know how long an issue or pull request spent in a particular label. This action can be configured to measure the time spent in a label. This is different from only wanting to measure issues with a specific label. If that is what you want, see the section on [configuring your search query](https://github.com/github/issue-metrics/blob/main/README.md#search_query-issues-or-pull-requests-open-or-closed).
213+
214+
Here is an example workflow that does this:
215+
216+
```yaml
217+
name: Monthly issue metrics
218+
on:
219+
workflow_dispatch:
220+
221+
jobs:
222+
build:
223+
name: issue metrics
224+
runs-on: ubuntu-latest
225+
226+
steps:
227+
228+
- name: Run issue-metrics tool
229+
uses: github/issue-metrics@v2
230+
env:
231+
GH_TOKEN: ${{ secrets.GH_TOKEN }}
232+
LABELS_TO_MEASURE: 'waiting-for-manager-approval,waiting-for-security-review'
233+
SEARCH_QUERY: 'repo:owner/repo is:issue created:2023-05-01..2023-05-31 -reason:"not planned"'
234+
235+
- name: Create issue
236+
uses: peter-evans/create-issue-from-file@v4
237+
with:
238+
title: Monthly issue metrics report
239+
content-filepath: ./issue_metrics.md
240+
assignees: <YOUR_GITHUB_HANDLE_HERE>
241+
242+
```
243+
244+
then the report will look like this:
245+
246+
```markdown
247+
# Issue Metrics
248+
249+
| Metric | Value |
250+
| --- | ---: |
251+
| Average time to first response | 0:50:44.666667 |
252+
| Average time to close | 6 days, 7:08:52 |
253+
| Average time to answer | 1 day |
254+
| Average time spent in waiting-for-manager-approval | 0:00:41 |
255+
| Average time spent in waiting-for-security-review | 2 days, 4:25:03 |
256+
| Number of items that remain open | 2 |
257+
| Number of items closed | 1 |
258+
| Total number of items created | 3 |
259+
260+
| Title | URL | Time to first response | Time to close | Time to answer | Time spent in waiting-for-manager-approval | Time spent in waiting-for-security-review |
261+
| --- | --- | --- | --- | --- | --- | --- |
262+
| Pull Request Title 1 | https://github.com/user/repo/pulls/1 | 0:05:26 | None | None | None | None |
263+
| Issue Title 2 | https://github.com/user/repo/issues/2 | 2:26:07 | None | None | 0:00:41 | 2 days, 4:25:03 |
264+
265+
```
266+
200267
## Example issue_metrics.md output
201268

202269
Here is the output with no hidden columns:
@@ -234,7 +301,7 @@ Here is the output with all hidable columns hidden:
234301
| --- | --- |
235302
| Discussion Title 1 | https://github.com/user/repo/discussions/1 |
236303
| Pull Request Title 2 | https://github.com/user/repo/pulls/2 |
237-
| Issue Title 3 | https://github.com/user/repo/issues/3 | 2:26:07 |
304+
| Issue Title 3 | https://github.com/user/repo/issues/3 |
238305
239306
```
240307

classes.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class IssueWithMetrics:
1717
time_to_close (timedelta, optional): The time it took to close the issue.
1818
time_to_answer (timedelta, optional): The time it took to answer the
1919
discussions in the issue.
20+
label_metrics (dict, optional): A dictionary containing the label metrics
2021
2122
"""
2223

@@ -27,9 +28,11 @@ def __init__(
2728
time_to_first_response=None,
2829
time_to_close=None,
2930
time_to_answer=None,
31+
labels_metrics=None,
3032
):
3133
self.title = title
3234
self.html_url = html_url
3335
self.time_to_first_response = time_to_first_response
3436
self.time_to_close = time_to_close
3537
self.time_to_answer = time_to_answer
38+
self.label_metrics = labels_metrics

issue_metrics.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from classes import IssueWithMetrics
3333
from discussions import get_discussions
3434
from json_writer import write_to_json
35+
from labels import get_average_time_in_labels, get_label_metrics
3536
from markdown_writer import write_to_markdown
3637
from time_to_answer import get_average_time_to_answer, measure_time_to_answer
3738
from time_to_close import get_average_time_to_close, measure_time_to_close
@@ -102,6 +103,7 @@ def auth_to_github() -> github3.GitHub:
102103
def get_per_issue_metrics(
103104
issues: Union[List[dict], List[github3.issues.Issue]], # type: ignore
104105
discussions: bool = False,
106+
labels: Union[List[str], None] = None,
105107
) -> tuple[List, int, int]:
106108
"""
107109
Calculate the metrics for each issue/pr/discussion in a list provided.
@@ -111,6 +113,7 @@ def get_per_issue_metrics(
111113
GitHub issues or discussions.
112114
discussions (bool, optional): Whether the issues are discussions or not.
113115
Defaults to False.
116+
labels (List[str]): A list of labels to measure time spent in. Defaults to empty list.
114117
115118
Returns:
116119
tuple[List[IssueWithMetrics], int, int]: A tuple containing a
@@ -130,6 +133,7 @@ def get_per_issue_metrics(
130133
None,
131134
None,
132135
None,
136+
None,
133137
)
134138
issue_with_metrics.time_to_first_response = measure_time_to_first_response(
135139
None, issue
@@ -147,10 +151,13 @@ def get_per_issue_metrics(
147151
None,
148152
None,
149153
None,
154+
None,
150155
)
151156
issue_with_metrics.time_to_first_response = measure_time_to_first_response(
152157
issue, None
153158
)
159+
if labels:
160+
issue_with_metrics.label_metrics = get_label_metrics(issue, labels)
154161
if issue.state == "closed": # type: ignore
155162
issue_with_metrics.time_to_close = measure_time_to_close(issue, None)
156163
num_issues_closed += 1
@@ -240,13 +247,24 @@ def main():
240247
(ie. repo:owner/repo) or an organization (ie. org:organization)"
241248
)
242249

250+
# Determine if there are label to measure
251+
labels = os.environ.get("LABELS_TO_MEASURE")
252+
if labels:
253+
labels = labels.split(",")
254+
else:
255+
labels = []
256+
243257
# Search for issues
244258
# If type:discussions is in the search_query, search for discussions using get_discussions()
245259
if "type:discussions" in search_query:
260+
if labels:
261+
raise ValueError(
262+
"The search query for discussions cannot include labels to measure"
263+
)
246264
issues = get_discussions(token, search_query)
247265
if len(issues) <= 0:
248266
print("No discussions found")
249-
write_to_markdown(None, None, None, None, None, None)
267+
write_to_markdown(None, None, None, None, None, None, None)
250268
return
251269
else:
252270
if owner is None or repo_name is None:
@@ -257,13 +275,14 @@ def main():
257275
issues = search_issues(search_query, github_connection)
258276
if len(issues.items) <= 0:
259277
print("No issues found")
260-
write_to_markdown(None, None, None, None, None, None)
278+
write_to_markdown(None, None, None, None, None, None, None)
261279
return
262280

263281
# Get all the metrics
264282
issues_with_metrics, num_issues_open, num_issues_closed = get_per_issue_metrics(
265283
issues,
266284
discussions="type:discussions" in search_query,
285+
labels=labels,
267286
)
268287

269288
average_time_to_first_response = get_average_time_to_first_response(
@@ -275,12 +294,17 @@ def main():
275294

276295
average_time_to_answer = get_average_time_to_answer(issues_with_metrics)
277296

297+
# Get the average time in label for each label and store it in a dictionary
298+
# where the key is the label and the value is the average time
299+
average_time_in_labels = get_average_time_in_labels(issues_with_metrics, labels)
300+
278301
# Write the results to json and a markdown file
279302
write_to_json(
280303
issues_with_metrics,
281304
average_time_to_first_response,
282305
average_time_to_close,
283306
average_time_to_answer,
307+
average_time_in_labels,
284308
num_issues_open,
285309
num_issues_closed,
286310
)
@@ -289,8 +313,10 @@ def main():
289313
average_time_to_first_response,
290314
average_time_to_close,
291315
average_time_to_answer,
316+
average_time_in_labels,
292317
num_issues_open,
293318
num_issues_closed,
319+
labels,
294320
)
295321

296322

json_writer.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def write_to_json(
2727
average_time_to_first_response: Union[timedelta, None],
2828
average_time_to_close: Union[timedelta, None],
2929
average_time_to_answer: Union[timedelta, None],
30+
average_time_in_labels: Union[dict, None],
3031
num_issues_opened: Union[int, None],
3132
num_issues_closed: Union[int, None],
3233
) -> str:
@@ -48,13 +49,18 @@ def write_to_json(
4849
"time_to_first_response": "3 days, 0:00:00",
4950
"time_to_close": "6 days, 0:00:00",
5051
"time_to_answer": "None",
52+
"label_metrics": {
53+
"bug": "1 day, 16:24:12"
54+
}
5155
},
5256
{
5357
"title": "Issue 2",
5458
"html_url": "https://github.com/owner/repo/issues/2",
5559
"time_to_first_response": "2 days, 0:00:00",
5660
"time_to_close": "4 days, 0:00:00",
5761
"time_to_answer": "1 day, 0:00:00",
62+
"label_metrics": {
63+
}
5864
},
5965
],
6066
}
@@ -66,10 +72,15 @@ def write_to_json(
6672
return ""
6773

6874
# Create a dictionary with the metrics
75+
labels_metrics = {}
76+
if average_time_in_labels:
77+
for label, time in average_time_in_labels.items():
78+
labels_metrics[label] = str(time)
6979
metrics = {
7080
"average_time_to_first_response": str(average_time_to_first_response),
7181
"average_time_to_close": str(average_time_to_close),
7282
"average_time_to_answer": str(average_time_to_answer),
83+
"average_time_in_labels": labels_metrics,
7384
"num_items_opened": num_issues_opened,
7485
"num_items_closed": num_issues_closed,
7586
"total_item_count": len(issues_with_metrics),
@@ -78,13 +89,18 @@ def write_to_json(
7889
# Create a list of dictionaries with the issues and metrics
7990
issues = []
8091
for issue in issues_with_metrics:
92+
formatted_label_metrics = {}
93+
if issue.label_metrics:
94+
for label, time in issue.label_metrics.items():
95+
formatted_label_metrics[label] = str(time)
8196
issues.append(
8297
{
8398
"title": issue.title,
8499
"html_url": issue.html_url,
85100
"time_to_first_response": str(issue.time_to_first_response),
86101
"time_to_close": str(issue.time_to_close),
87102
"time_to_answer": str(issue.time_to_answer),
103+
"label_metrics": formatted_label_metrics,
88104
}
89105
)
90106

0 commit comments

Comments
 (0)