Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add time in labels #45

Merged
merged 11 commits into from
Jul 14, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ SEARCH_QUERY = "repo:owner/repo is:open is:issue"
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,
75 changes: 71 additions & 4 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 in waiting-for-manager-approval | 0:00:41 |
| Average time in waiting-for-security-review | 2 days, 4:25:03 |
zkoppert marked this conversation as resolved.
Show resolved Hide resolved
| 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 in waiting-for-manager-approval | Time in waiting-for-security-review |
zkoppert marked this conversation as resolved.
Show resolved Hide resolved
| --- | --- | --- | --- | --- | --- | --- |
| 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
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