Skip to content

feat: add assignee support to issue metrics reporting #540

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

Merged
merged 8 commits into from
Jun 17, 2025
Merged
Show file tree
Hide file tree
Changes from all 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ This action can be configured to authenticate with GitHub App Installation or Pe
| ----------------------------- | -------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GH_ENTERPRISE_URL` | False | `""` | URL of GitHub Enterprise instance to use for auth instead of github.com |
| `RATE_LIMIT_BYPASS` | False | `false` | If set to `true`, the rate limit will be bypassed. This is useful if being run on an local GitHub server with rate limiting disabled. |
| `HIDE_ASSIGNEE` | False | False | If set to `true`, the assignee will not be displayed in the generated Markdown file. |
| `HIDE_AUTHOR` | False | False | If set to `true`, the author will not be displayed in the generated Markdown file. |
| `HIDE_ITEMS_CLOSED_COUNT` | False | False | If set to `true`, the number of items closed metric 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. |
Expand Down
6 changes: 6 additions & 0 deletions classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class IssueWithMetrics:
title (str): The title of the issue.
html_url (str): The URL of the issue on GitHub.
author (str): The author of the issue.
assignee (str, optional): The primary assignee of the issue.
assignees (list, optional): All assignees of the issue.
time_to_first_response (timedelta, optional): The time it took to
get the first response to the issue.
time_to_close (timedelta, optional): The time it took to close the issue.
Expand All @@ -38,10 +40,14 @@ def __init__(
labels_metrics=None,
mentor_activity=None,
created_at=None,
assignee=None,
assignees=None,
):
self.title = title
self.html_url = html_url
self.author = author
self.assignee = assignee
self.assignees = assignees or []
self.time_to_first_response = time_to_first_response
self.time_to_close = time_to_close
self.time_to_answer = time_to_answer
Expand Down
6 changes: 6 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class EnvVars:
authentication
gh_token (str | None): GitHub personal access token (PAT) for API authentication
ghe (str): The GitHub Enterprise URL to use for authentication
hide_assignee (bool): If true, the assignee's information is hidden in the output
hide_author (bool): If true, the author's information is hidden in the output
hide_items_closed_count (bool): If true, the number of items closed metric is hidden
in the output
Expand Down Expand Up @@ -64,6 +65,7 @@ def __init__(
gh_app_enterprise_only: bool,
gh_token: str | None,
ghe: str | None,
hide_assignee: bool,
hide_author: bool,
hide_items_closed_count: bool,
hide_label_metrics: bool,
Expand Down Expand Up @@ -92,6 +94,7 @@ def __init__(
self.ghe = ghe
self.ignore_users = ignore_user
self.labels_to_measure = labels_to_measure
self.hide_assignee = hide_assignee
self.hide_author = hide_author
self.hide_items_closed_count = hide_items_closed_count
self.hide_label_metrics = hide_label_metrics
Expand Down Expand Up @@ -119,6 +122,7 @@ def __repr__(self):
f"{self.gh_app_enterprise_only},"
f"{self.gh_token},"
f"{self.ghe},"
f"{self.hide_assignee},"
f"{self.hide_author},"
f"{self.hide_items_closed_count}),"
f"{self.hide_label_metrics},"
Expand Down Expand Up @@ -226,6 +230,7 @@ def get_env_vars(test: bool = False) -> EnvVars:
draft_pr_tracking = get_bool_env_var("DRAFT_PR_TRACKING", False)

# Hidden columns
hide_assignee = get_bool_env_var("HIDE_ASSIGNEE", False)
hide_author = get_bool_env_var("HIDE_AUTHOR", False)
hide_items_closed_count = get_bool_env_var("HIDE_ITEMS_CLOSED_COUNT", False)
hide_label_metrics = get_bool_env_var("HIDE_LABEL_METRICS", False)
Expand All @@ -246,6 +251,7 @@ def get_env_vars(test: bool = False) -> EnvVars:
gh_app_enterprise_only,
gh_token,
ghe,
hide_assignee,
hide_author,
hide_items_closed_count,
hide_label_metrics,
Expand Down
17 changes: 17 additions & 0 deletions issue_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ def get_per_issue_metrics(
None,
None,
)
# Discussions typically don't have assignees in the same way as issues/PRs
issue_with_metrics.assignee = None
issue_with_metrics.assignees = []
if env_vars.hide_time_to_first_response is False:
issue_with_metrics.time_to_first_response = (
measure_time_to_first_response(None, issue, ignore_users)
Expand Down Expand Up @@ -119,6 +122,20 @@ def get_per_issue_metrics(
author=issue.user["login"], # type: ignore
)

# Extract assignee information from the issue
issue_dict = issue.issue.as_dict() # type: ignore
assignee = None
assignees = []

if issue_dict.get("assignee"):
assignee = issue_dict["assignee"]["login"]

if issue_dict.get("assignees"):
assignees = [a["login"] for a in issue_dict["assignees"]]

issue_with_metrics.assignee = assignee
issue_with_metrics.assignees = assignees

# Check if issue is actually a pull request
pull_request, ready_for_review_at = None, None
if issue.issue.pull_request_urls: # type: ignore
Expand Down
2 changes: 2 additions & 0 deletions json_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ def write_to_json(
"title": issue.title,
"html_url": issue.html_url,
"author": issue.author,
"assignee": issue.assignee,
"assignees": issue.assignees,
"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),
Expand Down
13 changes: 13 additions & 0 deletions markdown_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ def get_non_hidden_columns(labels) -> List[str]:
env_vars = get_env_vars()

# Find the number of columns and which are to be hidden
hide_assignee = env_vars.hide_assignee
if not hide_assignee:
columns.append("Assignee")

hide_author = env_vars.hide_author
if not hide_author:
columns.append("Author")
Expand Down Expand Up @@ -203,6 +207,15 @@ def write_to_markdown(
)
else:
file.write(f"| {issue.title} | {issue.html_url} |")
if "Assignee" in columns:
if issue.assignees:
assignee_links = [
f"[{assignee}](https://{endpoint}/{assignee})"
for assignee in issue.assignees
]
file.write(f" {', '.join(assignee_links)} |")
else:
file.write(" None |")
if "Author" in columns:
file.write(f" [{issue.author}](https://{endpoint}/{issue.author}) |")
if "Time to first response" in columns:
Expand Down
197 changes: 197 additions & 0 deletions test_assignee_functionality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Test assignee functionality added to issue metrics."""

import os
import unittest
from unittest.mock import patch

from classes import IssueWithMetrics
from markdown_writer import get_non_hidden_columns


class TestAssigneeFunctionality(unittest.TestCase):
"""Test suite for the assignee functionality."""

@patch.dict(
os.environ,
{
"GH_TOKEN": "test_token",
"SEARCH_QUERY": "is:issue is:open repo:user/repo",
"HIDE_ASSIGNEE": "false",
"HIDE_AUTHOR": "false",
},
clear=True,
)
def test_get_non_hidden_columns_includes_assignee_by_default(self):
"""Test that assignee column is included by default."""
columns = get_non_hidden_columns(labels=None)
self.assertIn("Assignee", columns)
self.assertIn("Author", columns)

@patch.dict(
os.environ,
{
"GH_TOKEN": "test_token",
"SEARCH_QUERY": "is:issue is:open repo:user/repo",
"HIDE_ASSIGNEE": "true",
"HIDE_AUTHOR": "false",
},
clear=True,
)
def test_get_non_hidden_columns_hides_assignee_when_env_set(self):
"""Test that assignee column is hidden when HIDE_ASSIGNEE is true."""
columns = get_non_hidden_columns(labels=None)
self.assertNotIn("Assignee", columns)
self.assertIn("Author", columns)

@patch.dict(
os.environ,
{
"GH_TOKEN": "test_token",
"SEARCH_QUERY": "is:issue is:open repo:user/repo",
"HIDE_ASSIGNEE": "false",
"HIDE_AUTHOR": "true",
},
clear=True,
)
def test_get_non_hidden_columns_shows_assignee_but_hides_author(self):
"""Test that assignee can be shown while author is hidden."""
columns = get_non_hidden_columns(labels=None)
self.assertIn("Assignee", columns)
self.assertNotIn("Author", columns)

@patch.dict(
os.environ,
{
"GH_TOKEN": "test_token",
"SEARCH_QUERY": "is:issue is:open repo:user/repo",
"HIDE_ASSIGNEE": "true",
"HIDE_AUTHOR": "true",
},
clear=True,
)
def test_get_non_hidden_columns_hides_both_assignee_and_author(self):
"""Test that both assignee and author can be hidden."""
columns = get_non_hidden_columns(labels=None)
self.assertNotIn("Assignee", columns)
self.assertNotIn("Author", columns)

def test_assignee_column_position(self):
"""Test that assignee column appears before author column."""
with patch.dict(
os.environ,
{
"GH_TOKEN": "test_token",
"SEARCH_QUERY": "is:issue is:open repo:user/repo",
"HIDE_ASSIGNEE": "false",
"HIDE_AUTHOR": "false",
},
clear=True,
):
columns = get_non_hidden_columns(labels=None)
assignee_index = columns.index("Assignee")
author_index = columns.index("Author")
self.assertLess(
assignee_index,
author_index,
"Assignee column should appear before Author column",
)

def test_multiple_assignees_rendering_logic(self):
"""Test that multiple assignees are rendered correctly in assignee column."""

# Test the assignee rendering logic directly
endpoint = "github.com"
columns = ["Title", "URL", "Assignee", "Author"]

# Initialize variables
multiple_output = ""
single_output = ""
none_output = ""

# Test case 1: Multiple assignees
issue_multiple = IssueWithMetrics(
title="Test Issue with Multiple Assignees",
html_url="https://github.com/test/repo/issues/1",
author="testuser",
assignee="alice",
assignees=["alice", "bob", "charlie"],
)

# Simulate the new rendering logic
if "Assignee" in columns:
if issue_multiple.assignees:
assignee_links = [
f"[{assignee}](https://{endpoint}/{assignee})"
for assignee in issue_multiple.assignees
]
multiple_output = f" {', '.join(assignee_links)} |"
else:
multiple_output = " None |"

expected_multiple = (
" [alice](https://github.com/alice), [bob](https://github.com/bob), "
"[charlie](https://github.com/charlie) |"
)
self.assertEqual(
multiple_output,
expected_multiple,
"Multiple assignees should be rendered as comma-separated links",
)

# Test case 2: Single assignee
issue_single = IssueWithMetrics(
title="Test Issue with Single Assignee",
html_url="https://github.com/test/repo/issues/2",
author="testuser",
assignee="alice",
assignees=["alice"],
)

if "Assignee" in columns:
if issue_single.assignees:
assignee_links = [
f"[{assignee}](https://{endpoint}/{assignee})"
for assignee in issue_single.assignees
]
single_output = f" {', '.join(assignee_links)} |"
else:
single_output = " None |"

expected_single = " [alice](https://github.com/alice) |"
self.assertEqual(
single_output,
expected_single,
"Single assignee should be rendered as a single link",
)

# Test case 3: No assignees
issue_none = IssueWithMetrics(
title="Test Issue with No Assignees",
html_url="https://github.com/test/repo/issues/3",
author="testuser",
assignee=None,
assignees=[],
)

if "Assignee" in columns:
if issue_none.assignees:
assignee_links = [
f"[{assignee}](https://{endpoint}/{assignee})"
for assignee in issue_none.assignees
]
none_output = f" {', '.join(assignee_links)} |"
else:
none_output = " None |"

expected_none = " None |"
self.assertEqual(
none_output, expected_none, "No assignees should be rendered as 'None'"
)

print(f"✅ Multiple assignees test: {expected_multiple}")
print(f"✅ Single assignee test: {expected_single}")
print(f"✅ No assignees test: {expected_none}")


if __name__ == "__main__":
unittest.main()
Loading
Loading