Skip to content

Commit

Permalink
Merge pull request #45 from github/labels
Browse files Browse the repository at this point in the history
feat: add time in labels
  • Loading branch information
zkoppert authored Jul 14, 2023
2 parents 7fbd86b + 3ecf559 commit 69c1c46
Show file tree
Hide file tree
Showing 13 changed files with 514 additions and 53 deletions.
2 changes: 2 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
GH_TOKEN = " "
SEARCH_QUERY = "repo:owner/repo is:open is:issue"
LABELS_TO_MEASURE = "waiting-for-review,waiting-for-manager"
HIDE_TIME_TO_FIRST_RESPONSE = False
HIDE_TIME_TO_CLOSE = False
HIDE_TIME_TO_ANSWER = False
HIDE_LABEL_METRICS = False
4 changes: 3 additions & 1 deletion .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ disable=
redefined-argument-from-local,
too-many-arguments,
too-few-public-methods,
duplicate-code,
duplicate-code,
too-many-locals,
too-many-branches,
77 changes: 72 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@

[![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)

This is a GitHub Action that searches for pull requests/issues/discussions in a repository and measures
the time to first response for each one. It then calculates the average time
to first response and writes the issues/pull requests/discussions with their metrics
to a Markdown file. The issues/pull requests/discussions to search for can be filtered by using a search query.
This is a GitHub Action that searches for pull requests/issues/discussions in a repository and measures and reports on
several metrics. The issues/pull requests/discussions to search for can be filtered by using a search query.

The metrics that are measured are:
| Metric | Description |
|--------|-------------|
| Time to first response | The time between when an issue/pull request/discussion is created and when the first comment or review is made. |
| Time to close | The time between when an issue/pull request/discussion is created and when it is closed. |
| Time to answer | (Discussions only) The time between when a discussion is created and when it is answered. |
| 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. |

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.

Expand Down Expand Up @@ -37,9 +43,11 @@ Below are the allowed configuration options:
|-----------------------|----------|---------|-------------|
| `GH_TOKEN` | True | | The GitHub Token used to scan the repository. Must have read access to all repository you are interested in scanning. |
| `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. |
| `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. |
| `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. |
| `HIDE_TIME_TO_CLOSE` | False | False | If set to true, the time to close will not be displayed in the generated markdown file. |
| `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. |
| `HIDE_LABEL_METRICS` | False | False | If set to true, the time in label metrics will not be displayed in the generated markdown file. |

### Example workflows

Expand Down Expand Up @@ -197,6 +205,65 @@ jobs:
assignees: <YOUR_GITHUB_HANDLE_HERE>
```
## Measuring time spent in labels
**Note**: The discussions API currently doesn't support the `LabeledEvent` so this action cannot measure the time spent in a label for discussions.

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).

Here is an example workflow that does this:

```yaml
name: Monthly issue metrics
on:
workflow_dispatch:
jobs:
build:
name: issue metrics
runs-on: ubuntu-latest
steps:
- name: Run issue-metrics tool
uses: github/issue-metrics@v2
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
LABELS_TO_MEASURE: 'waiting-for-manager-approval,waiting-for-security-review'
SEARCH_QUERY: 'repo:owner/repo is:issue created:2023-05-01..2023-05-31 -reason:"not planned"'
- name: Create issue
uses: peter-evans/create-issue-from-file@v4
with:
title: Monthly issue metrics report
content-filepath: ./issue_metrics.md
assignees: <YOUR_GITHUB_HANDLE_HERE>
```

then the report will look like this:

```markdown
# Issue Metrics
| Metric | Value |
| --- | ---: |
| Average time to first response | 0:50:44.666667 |
| Average time to close | 6 days, 7:08:52 |
| Average time to answer | 1 day |
| Average time spent in waiting-for-manager-approval | 0:00:41 |
| Average time spent in waiting-for-security-review | 2 days, 4:25:03 |
| Number of items that remain open | 2 |
| Number of items closed | 1 |
| Total number of items created | 3 |
| 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 |
| --- | --- | --- | --- | --- | --- | --- |
| Pull Request Title 1 | https://github.com/user/repo/pulls/1 | 0:05:26 | None | None | None | None |
| Issue Title 2 | https://github.com/user/repo/issues/2 | 2:26:07 | None | None | 0:00:41 | 2 days, 4:25:03 |
```

## Example issue_metrics.md output

Here is the output with no hidden columns:
Expand Down Expand Up @@ -234,7 +301,7 @@ Here is the output with all hidable columns hidden:
| --- | --- |
| Discussion Title 1 | https://github.com/user/repo/discussions/1 |
| Pull Request Title 2 | https://github.com/user/repo/pulls/2 |
| Issue Title 3 | https://github.com/user/repo/issues/3 | 2:26:07 |
| Issue Title 3 | https://github.com/user/repo/issues/3 |
```

Expand Down
3 changes: 3 additions & 0 deletions classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class IssueWithMetrics:
time_to_close (timedelta, optional): The time it took to close the issue.
time_to_answer (timedelta, optional): The time it took to answer the
discussions in the issue.
label_metrics (dict, optional): A dictionary containing the label metrics
"""

Expand All @@ -27,9 +28,11 @@ def __init__(
time_to_first_response=None,
time_to_close=None,
time_to_answer=None,
labels_metrics=None,
):
self.title = title
self.html_url = html_url
self.time_to_first_response = time_to_first_response
self.time_to_close = time_to_close
self.time_to_answer = time_to_answer
self.label_metrics = labels_metrics
30 changes: 28 additions & 2 deletions issue_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from classes import IssueWithMetrics
from discussions import get_discussions
from json_writer import write_to_json
from labels import get_average_time_in_labels, get_label_metrics
from markdown_writer import write_to_markdown
from time_to_answer import get_average_time_to_answer, measure_time_to_answer
from time_to_close import get_average_time_to_close, measure_time_to_close
Expand Down Expand Up @@ -102,6 +103,7 @@ def auth_to_github() -> github3.GitHub:
def get_per_issue_metrics(
issues: Union[List[dict], List[github3.issues.Issue]], # type: ignore
discussions: bool = False,
labels: Union[List[str], None] = None,
) -> tuple[List, int, int]:
"""
Calculate the metrics for each issue/pr/discussion in a list provided.
Expand All @@ -111,6 +113,7 @@ def get_per_issue_metrics(
GitHub issues or discussions.
discussions (bool, optional): Whether the issues are discussions or not.
Defaults to False.
labels (List[str]): A list of labels to measure time spent in. Defaults to empty list.
Returns:
tuple[List[IssueWithMetrics], int, int]: A tuple containing a
Expand All @@ -130,6 +133,7 @@ def get_per_issue_metrics(
None,
None,
None,
None,
)
issue_with_metrics.time_to_first_response = measure_time_to_first_response(
None, issue
Expand All @@ -147,10 +151,13 @@ def get_per_issue_metrics(
None,
None,
None,
None,
)
issue_with_metrics.time_to_first_response = measure_time_to_first_response(
issue, None
)
if labels:
issue_with_metrics.label_metrics = get_label_metrics(issue, labels)
if issue.state == "closed": # type: ignore
issue_with_metrics.time_to_close = measure_time_to_close(issue, None)
num_issues_closed += 1
Expand Down Expand Up @@ -240,13 +247,24 @@ def main():
(ie. repo:owner/repo) or an organization (ie. org:organization)"
)

# Determine if there are label to measure
labels = os.environ.get("LABELS_TO_MEASURE")
if labels:
labels = labels.split(",")
else:
labels = []

# Search for issues
# If type:discussions is in the search_query, search for discussions using get_discussions()
if "type:discussions" in search_query:
if labels:
raise ValueError(
"The search query for discussions cannot include labels to measure"
)
issues = get_discussions(token, search_query)
if len(issues) <= 0:
print("No discussions found")
write_to_markdown(None, None, None, None, None, None)
write_to_markdown(None, None, None, None, None, None, None)
return
else:
if owner is None or repo_name is None:
Expand All @@ -257,13 +275,14 @@ def main():
issues = search_issues(search_query, github_connection)
if len(issues.items) <= 0:
print("No issues found")
write_to_markdown(None, None, None, None, None, None)
write_to_markdown(None, None, None, None, None, None, None)
return

# Get all the metrics
issues_with_metrics, num_issues_open, num_issues_closed = get_per_issue_metrics(
issues,
discussions="type:discussions" in search_query,
labels=labels,
)

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

average_time_to_answer = get_average_time_to_answer(issues_with_metrics)

# Get the average time in label for each label and store it in a dictionary
# where the key is the label and the value is the average time
average_time_in_labels = get_average_time_in_labels(issues_with_metrics, labels)

# Write the results to json and a markdown file
write_to_json(
issues_with_metrics,
average_time_to_first_response,
average_time_to_close,
average_time_to_answer,
average_time_in_labels,
num_issues_open,
num_issues_closed,
)
Expand All @@ -289,8 +313,10 @@ def main():
average_time_to_first_response,
average_time_to_close,
average_time_to_answer,
average_time_in_labels,
num_issues_open,
num_issues_closed,
labels,
)


Expand Down
16 changes: 16 additions & 0 deletions json_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def write_to_json(
average_time_to_first_response: Union[timedelta, None],
average_time_to_close: Union[timedelta, None],
average_time_to_answer: Union[timedelta, None],
average_time_in_labels: Union[dict, None],
num_issues_opened: Union[int, None],
num_issues_closed: Union[int, None],
) -> str:
Expand All @@ -48,13 +49,18 @@ def write_to_json(
"time_to_first_response": "3 days, 0:00:00",
"time_to_close": "6 days, 0:00:00",
"time_to_answer": "None",
"label_metrics": {
"bug": "1 day, 16:24:12"
}
},
{
"title": "Issue 2",
"html_url": "https://github.com/owner/repo/issues/2",
"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": {
}
},
],
}
Expand All @@ -66,10 +72,15 @@ def write_to_json(
return ""

# Create a dictionary with the metrics
labels_metrics = {}
if average_time_in_labels:
for label, time in average_time_in_labels.items():
labels_metrics[label] = str(time)
metrics = {
"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_labels": labels_metrics,
"num_items_opened": num_issues_opened,
"num_items_closed": num_issues_closed,
"total_item_count": len(issues_with_metrics),
Expand All @@ -78,13 +89,18 @@ def write_to_json(
# Create a list of dictionaries with the issues and metrics
issues = []
for issue in issues_with_metrics:
formatted_label_metrics = {}
if issue.label_metrics:
for label, time in issue.label_metrics.items():
formatted_label_metrics[label] = str(time)
issues.append(
{
"title": issue.title,
"html_url": issue.html_url,
"time_to_first_response": str(issue.time_to_first_response),
"time_to_close": str(issue.time_to_close),
"time_to_answer": str(issue.time_to_answer),
"label_metrics": formatted_label_metrics,
}
)

Expand Down
Loading

0 comments on commit 69c1c46

Please sign in to comment.