From f052813cb19d69bdcb762fa7da6fab32cb32436f Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Thu, 8 Jun 2023 17:08:34 -0700 Subject: [PATCH 1/2] feat: add time to close Signed-off-by: Zack Koppert --- .pylintrc | 3 +- README.md | 28 ++--- issue_metrics.py | 259 ++++++++++++++++++++++++++++------------------- 3 files changed, 171 insertions(+), 119 deletions(-) diff --git a/.pylintrc b/.pylintrc index 5ac5cee..78bdaf5 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,3 +1,4 @@ [MESSAGES CONTROL] disable= - redefined-argument-from-local, \ No newline at end of file + redefined-argument-from-local, + too-many-arguments, \ No newline at end of file diff --git a/README.md b/README.md index 34461ec..957da8a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ This is a GitHub Action that searches for pull requests/issues in a repository and measures the time to first response for each issue. It then calculates the average time -to first response and writes the issues with their time to first response to a -Markdown file. The issues to search for can be filtered by using a search query. +to first response and writes the issues with their time to first response and time to close +to a Markdown file. The issues to search for can be filtered by using a search query. 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. @@ -19,7 +19,7 @@ If you need support using this project or have questions about it, please [open 1. Create a repository to host this GitHub Action or select an existing repository. 1. Create the env values from the sample workflow below (GH_TOKEN, REPOSITORY_URL, ISSUE_SEARCH_QUERY) with your information as repository secrets. More info on creating secrets can be found [here](https://docs.github.com/en/actions/security-guides/encrypted-secrets). -Note: Your GitHub token will need to have read/write access to the repository in the organization that you want evaluated +Note: Your GitHub token will need to have read access to the repository in the organization that you want evaluated 1. Copy the below example workflow to your repository and put it in the `.github/workflows/` directory with the file extension `.yml` (ie. `.github/workflows/issue-metrics.yml`) ### Example workflow @@ -61,15 +61,19 @@ jobs: ```markdown # Issue Metrics -Average time to first response: 2 days, 3:30:00 -Number of issues that remain open: 0 -Number of issues closed: 2 -Total number of issues created: 2 - -| Title | URL | TTFR | -| --- | --- | ---: | -| Issue 2 | https://github.com/user/repo/issues/2 | 3 days, 4:30:00 | -| Issue 1 | https://github.com/user/repo/issues/1 | 1 day, 2:30:00 | +| Metric | Value | +| --- | ---: | +| Average time to first response | 0:50:44.666667 | +| Average time to close | 6 days, 7:08:52 | +| Number of issues that remain open | 2 | +| Number of issues closed | 1 | +| Total number of issues created | 3 | + +| Title | URL | Time to first response | Time to close +| --- | --- | ---: | ---: | +| Issue Title 1 | https://github.com/user/repo/issues/1 | 0:00:41 | 6 days, 7:08:52 | +| Issue Title 2 | https://github.com/user/repo/issues/2 | 0:05:26 | None | +| Issue Title 3 | https://github.com/user/repo/issues/3 | 2:26:07 | None | ``` diff --git a/issue_metrics.py b/issue_metrics.py index 9923eae..4ee41ee 100644 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -1,16 +1,18 @@ -"""A script for measuring time to first response for GitHub issues. +"""A script for measuring time to first response and time to close for GitHub issues. This script uses the GitHub API to search for issues in a repository and measure -the time to first response for each issue. It then calculates the average time -to first response and writes the issues with their time to first response to a -markdown file. +the time to first response and time to close for each issue. It then calculates +the average time to first response and time to close and writes the issues with +their metrics to a markdown file. Functions: search_issues: Search for issues in a GitHub repository. auth_to_github: Authenticate to the GitHub API. measure_time_to_first_response: Measure the time to first response for a GitHub issue. get_average_time_to_first_response: Calculate the average time to first response for - a list of issues. + a list of issues. + measure_time_to_close: Measure the time to close for a GitHub issue. + get_average_time_to_close: Calculate the average time to close for a list of issues. write_to_markdown: Write the issues with metrics to a markdown file. """ @@ -68,121 +70,92 @@ def auth_to_github(): return github_connection # type: ignore -def measure_time_to_first_response(issues): - """Measure the time to first response for each issue. +def measure_time_to_first_response(issue): + """Measure the time to first response for a single issue. Args: - issues (list of github3.Issue): A list of GitHub issues. + issue (issue): A list of GitHub issues. Returns: - list of tuple: A list of tuples containing a GitHub issue - title, url, and its time to first response. - - Raises: - TypeError: If the input is not a list of GitHub issues. + time to first response (datetime.timedelta): The time to first response for the issue. """ - issues_with_metrics = [] - for issue in issues: - # Get the first comment - if issue.comments <= 0: - first_comment_time = None - time_to_first_response = None - else: - comments = issue.issue.comments( - number=1, sort="created", direction="asc" - ) # type: ignore - for comment in comments: - # Get the created_at time for the first comment - first_comment_time = comment.created_at # type: ignore - - # Get the created_at time for the issue - issue_time = datetime.fromisoformat(issue.created_at) # type: ignore - - # Calculate the time between the issue and the first comment - time_to_first_response = first_comment_time - issue_time # type: ignore - - # Add the issue to the list of issues with metrics - issues_with_metrics.append( - [ - issue.title, - issue.html_url, - time_to_first_response, - ] - ) + # Get the first comment + if issue.comments <= 0: + first_comment_time = None + time_to_first_response = None + else: + comments = issue.issue.comments( + number=1, sort="created", direction="asc" + ) # type: ignore + for comment in comments: + # Get the created_at time for the first comment + first_comment_time = comment.created_at # type: ignore - return issues_with_metrics + # Get the created_at time for the issue + issue_time = datetime.fromisoformat(issue.created_at) # type: ignore + # Calculate the time between the issue and the first comment + time_to_first_response = first_comment_time - issue_time # type: ignore -def get_average_time_to_first_response(issues): - """Calculate the average time to first response for a list of issues. + return time_to_first_response + + +def measure_time_to_close(issue): + """Measure the time it takes to close an issue. Args: - issues (list of github3.Issue): A list of GitHub issues with the time to - first response added as an attribute. + issue (github3.Issue): A GitHub issue. Returns: - datetime.timedelta: The average time to first response for the issues in seconds. - - Raises: - TypeError: If the input is not a list of GitHub issues. + datetime.timedelta: The time it takes to close the issue. """ - total_time_to_first_response = 0 - for issue in issues: - total_time_to_first_response += issue[2].total_seconds() + if issue.state != "closed": + raise ValueError("Issue must be closed to measure time to close.") - average_seconds_to_first_response = total_time_to_first_response / len( - issues - ) # type: ignore + closed_at = datetime.fromisoformat(issue.closed_at) + created_at = datetime.fromisoformat(issue.created_at) - # Print the average time to first response converting seconds to a readable time format - print( - f"Average time to first response: {timedelta(seconds=average_seconds_to_first_response)}" - ) + time_to_close = closed_at - created_at - return timedelta(seconds=average_seconds_to_first_response) + return time_to_close -def get_number_of_issues_open(issues): - """Get the number of issues that were opened. +def get_average_time_to_first_response(issues): + """Calculate the average time to first response for a list of issues. Args: - issues (list of github3.Issue): A list of GitHub issues. + issues (IssueWithMetrics): A list of GitHub issues with metrics attached. Returns: - int: The number of issues that were opened. + datetime.timedelta: The average time to first response for the issues in seconds. """ - num_issues_opened = 0 + total_time_to_first_response = 0 + none_count = 0 for issue in issues: - if issue.state == "open": - num_issues_opened += 1 - - return num_issues_opened - - -def get_number_of_issues_closed(issues): - """Get the number of issues that were closed. - - Args: - issues (list of github3.Issue): A list of GitHub issues. + if issue.time_to_first_response: + total_time_to_first_response += issue.time_to_first_response.total_seconds() + else: + none_count += 1 - Returns: - int: The number of issues that were closed. + average_seconds_to_first_response = total_time_to_first_response / ( + len(issues) - none_count + ) # type: ignore - """ - num_issues_closed = 0 - for issue in issues: - if issue.state == "closed": - num_issues_closed += 1 + # Print the average time to first response converting seconds to a readable time format + print( + f"Average time to first response: {timedelta(seconds=average_seconds_to_first_response)}" + ) - return num_issues_closed + return timedelta(seconds=average_seconds_to_first_response) def write_to_markdown( issues_with_metrics, average_time_to_first_response, + average_time_to_close, num_issues_opened, num_issues_closed, file=None, @@ -190,10 +163,10 @@ def write_to_markdown( """Write the issues with metrics to a markdown file. Args: - issues_with_metrics (list of tuple): A list of tuples containing a GitHub issue - and its time to first response. + issues_with_metrics (IssueWithMetrics): A list of GitHub issues with metrics average_time_to_first_response (datetime.timedelta): The average time to first response for the issues. + average_time_to_close (datetime.timedelta): The average time to close for the issues. file (file object, optional): The file object to write to. If not provided, a file named "issue_metrics.md" will be created. num_issues_opened (int): The number of issues that remain opened. @@ -206,37 +179,82 @@ def write_to_markdown( if ( not issues_with_metrics and not average_time_to_first_response + and not average_time_to_close and not num_issues_opened and not num_issues_closed ): with file or open("issue_metrics.md", "w", encoding="utf-8") as file: file.write("no issues found for the given search criteria\n\n") else: - issues_with_metrics.sort(key=lambda x: x[1], reverse=True) + # Sort the issues by time to first response + issues_with_metrics.sort(key=lambda x: x.time_to_first_response) with file or open("issue_metrics.md", "w", encoding="utf-8") as file: file.write("# Issue Metrics\n\n") + file.write("| Metric | Value |\n") + file.write("| --- | ---: |\n") file.write( - f"Average time to first response: {average_time_to_first_response}\n" + f"| Average time to first response | {average_time_to_first_response} |\n" ) - file.write(f"Number of issues that remain open: {num_issues_opened}\n") - file.write(f"Number of issues closed: {num_issues_closed}\n") + file.write(f"| Average time to close | {average_time_to_close} |\n") + file.write(f"| Number of issues that remain open | {num_issues_opened} |\n") + file.write(f"| Number of issues closed | {num_issues_closed} |\n") file.write( - f"Total number of issues created: {len(issues_with_metrics)}\n\n" + f"| Total number of issues created | {len(issues_with_metrics)} |\n\n" ) - file.write("| Title | URL | TTFR |\n") - file.write("| --- | --- | ---: |\n") - for title, url, ttfr in issues_with_metrics: - file.write(f"| {title} | {url} | {ttfr} |\n") + file.write("| Title | URL | Time to first response | Time to close \n") + file.write("| --- | --- | ---: | ---: |\n") + for issue in issues_with_metrics: + file.write( + f"| " + f"{issue.title} | " + f"{issue.html_url} | " + f"{issue.time_to_first_response} |" + f" {issue.time_to_close} |" + f"\n" + ) print("Wrote issue metrics to issue_metrics.md") +def get_average_time_to_close(issues_with_metrics): + """Calculate the average time to close for a list of issues. + + Args: + issues_with_metrics (list): A list of issues with metrics. + Each issue should be a issue_with_metrics tuple. + + Returns: + datetime.timedelta: The average time to close for the issues. + + """ + # Filter out issues with no time to close + issues_with_time_to_close = [ + issue for issue in issues_with_metrics if issue.time_to_close is not None + ] + + # Calculate the total time to close for all issues + total_time_to_close = sum( + [issue.time_to_close for issue in issues_with_time_to_close], timedelta() + ) + + # Calculate the average time to close + num_issues_with_time_to_close = len(issues_with_time_to_close) + if num_issues_with_time_to_close > 0: + average_time_to_close = total_time_to_close / num_issues_with_time_to_close + else: + average_time_to_close = None + + # Print the average time to close converting seconds to a readable time format + print(f"Average time to close: {average_time_to_close}") + return average_time_to_close + + def main(): """Run the issue-metrics script. This function authenticates to GitHub, searches for issues using the ISSUE_SEARCH_QUERY environment variable, measures the time to first response - for each issue, calculates the average time to first response, and writes the - results to a markdown file. + and close for each issue, calculates the average time to first response, + and writes the results to a markdown file. Raises: ValueError: If the ISSUE_SEARCH_QUERY environment variable is not set. @@ -265,25 +283,54 @@ def main(): issues = search_issues(repo_url, issue_search_query, github_connection) if len(issues.items) <= 0: print("No issues found") - write_to_markdown(None, None, None, None) - + write_to_markdown(None, None, None, None, None) return - # Find the time to first response, average, open, and closed issues - issues_with_ttfr = measure_time_to_first_response(issues) + + # Find the time to first response, time to close, average, open, and closed issues + issues_with_metrics = [] + num_issues_open = 0 + num_issues_closed = 0 + + for issue in issues: + issue_with_metrics = IssueWithMetrics() + issue_with_metrics.title = issue.title # type: ignore + issue_with_metrics.html_url = issue.html_url # type: ignore + issue_with_metrics.time_to_first_response = measure_time_to_first_response( + issue + ) + if issue.state == "closed": # type: ignore + issue_with_metrics.time_to_close = measure_time_to_close(issue) # type: ignore + if issue.state == "open": # type: ignore + num_issues_open += 1 + if issue.state == "closed": # type: ignore + num_issues_closed += 1 + issues_with_metrics.append(issue_with_metrics) + average_time_to_first_response = get_average_time_to_first_response( - issues_with_ttfr + issues_with_metrics ) - num_issues_open = get_number_of_issues_open(issues) - num_issues_closed = get_number_of_issues_closed(issues) + average_time_to_close = None + if num_issues_closed > 0: + average_time_to_close = get_average_time_to_close(issues_with_metrics) # Write the results to a markdown file write_to_markdown( - issues_with_ttfr, + issues_with_metrics, average_time_to_first_response, + average_time_to_close, num_issues_open, num_issues_closed, ) +class IssueWithMetrics: + """A class to represent a GitHub issue with metrics.""" + + title = "" + html_url = "" + time_to_first_response = None + time_to_close = None + + if __name__ == "__main__": main() From 5b865f7b224401c8f4193c87f5f76a17ce769447 Mon Sep 17 00:00:00 2001 From: Zack Koppert Date: Thu, 8 Jun 2023 18:05:24 -0700 Subject: [PATCH 2/2] fix: fix tests Signed-off-by: Zack Koppert --- .pylintrc | 3 +- issue_metrics.py | 42 +++--- test_issue_metrics.py | 296 +++++++++++++++++++++++++----------------- 3 files changed, 206 insertions(+), 135 deletions(-) diff --git a/.pylintrc b/.pylintrc index 78bdaf5..1025fbc 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,4 +1,5 @@ [MESSAGES CONTROL] disable= redefined-argument-from-local, - too-many-arguments, \ No newline at end of file + too-many-arguments, + too-few-public-methods, \ No newline at end of file diff --git a/issue_metrics.py b/issue_metrics.py index 4ee41ee..298315b 100644 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -74,7 +74,7 @@ def measure_time_to_first_response(issue): """Measure the time to first response for a single issue. Args: - issue (issue): A list of GitHub issues. + issue (issue): A GitHub issue. Returns: time to first response (datetime.timedelta): The time to first response for the issue. @@ -271,13 +271,7 @@ def main(): github_connection = auth_to_github() # Get the environment variables for use in the script - issue_search_query = os.getenv("ISSUE_SEARCH_QUERY") - if not issue_search_query: - raise ValueError("ISSUE_SEARCH_QUERY environment variable not set") - - repo_url = os.getenv("REPOSITORY_URL") - if not repo_url: - raise ValueError("REPOSITORY_URL environment variable not set") + issue_search_query, repo_url = get_env_vars() # Search for issues issues = search_issues(repo_url, issue_search_query, github_connection) @@ -292,9 +286,12 @@ def main(): num_issues_closed = 0 for issue in issues: - issue_with_metrics = IssueWithMetrics() - issue_with_metrics.title = issue.title # type: ignore - issue_with_metrics.html_url = issue.html_url # type: ignore + issue_with_metrics = IssueWithMetrics( + issue.title, # type: ignore + issue.html_url, # type: ignore + None, + None, + ) issue_with_metrics.time_to_first_response = measure_time_to_first_response( issue ) @@ -323,13 +320,28 @@ def main(): ) +def get_env_vars(): + """Get the environment variables for use in the script.""" + issue_search_query = os.getenv("ISSUE_SEARCH_QUERY") + if not issue_search_query: + raise ValueError("ISSUE_SEARCH_QUERY environment variable not set") + + repo_url = os.getenv("REPOSITORY_URL") + if not repo_url: + raise ValueError("REPOSITORY_URL environment variable not set") + return issue_search_query, repo_url + + class IssueWithMetrics: """A class to represent a GitHub issue with metrics.""" - title = "" - html_url = "" - time_to_first_response = None - time_to_close = None + def __init__( + self, title, html_url, time_to_first_response=None, time_to_close=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 if __name__ == "__main__": diff --git a/test_issue_metrics.py b/test_issue_metrics.py index 1b72717..58fdc89 100644 --- a/test_issue_metrics.py +++ b/test_issue_metrics.py @@ -15,12 +15,16 @@ import os import unittest from datetime import datetime, timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, mock_open, patch import issue_metrics from issue_metrics import ( + IssueWithMetrics, auth_to_github, + get_average_time_to_close, get_average_time_to_first_response, + get_env_vars, + measure_time_to_close, measure_time_to_first_response, search_issues, write_to_markdown, @@ -116,47 +120,76 @@ def test_measure_time_to_first_response(self): mock_comment1.created_at = datetime.fromisoformat("2023-01-02T00:00:00Z") mock_issue1.issue.comments.return_value = [mock_comment1] - mock_issue2 = MagicMock() - mock_issue2.comments = 1 - mock_issue2.created_at = "2023-01-03T00:00:00Z" - - mock_comment2 = MagicMock() - mock_comment2.created_at = datetime.fromisoformat("2023-01-04T00:00:00Z") - mock_issue2.issue.comments.return_value = [mock_comment2] - - mock_issues = [mock_issue1, mock_issue2] - # Call the function - issues_with_metrics = measure_time_to_first_response(mock_issues) + result = measure_time_to_first_response(mock_issue1) + expected_result = timedelta(days=1) # Check the results - self.assertEqual(len(issues_with_metrics), 2) - self.assertEqual(issues_with_metrics[0][2], timedelta(days=1)) - self.assertEqual(issues_with_metrics[1][2], timedelta(days=1)) + self.assertEqual(result, expected_result) def test_measure_time_to_first_response_no_comments(self): """Test that measure_time_to_first_response returns empty for an issue with no comments.""" # Set up mock issues with no comments - mock_issue1 = MagicMock( - comments=0, - created_at="2023-01-01T00:00:00Z", - ) + mock_issue1 = MagicMock() + mock_issue1.comments = 0 + mock_issue1.created_at = "2023-01-01T00:00:00Z" - mock_issue2 = MagicMock( - comments=0, - created_at="2023-01-01T00:00:00Z", - ) + # Call the function + result = measure_time_to_first_response(mock_issue1) + expected_result = None - mock_issues = [mock_issue1, mock_issue2] + # Check the results + self.assertEqual(result, expected_result) - # Call measure_time_to_first_response and check that it returns None - time_to_first_response = issue_metrics.measure_time_to_first_response( - mock_issues - ) - self.assertEqual(len(time_to_first_response), 2) - self.assertEqual(time_to_first_response[0][2], None) - self.assertEqual(time_to_first_response[1][2], None) +class TestGetAverageTimeToClose(unittest.TestCase): + """Test suite for the get_average_time_to_close function.""" + + def test_get_average_time_to_close(self): + """Test that the function correctly calculates the average time to close.""" + # Create mock data + issues_with_metrics = [ + IssueWithMetrics( + "Issue 1", + "https://github.com/user/repo/issues/1", + None, + timedelta(days=2), + ), + IssueWithMetrics( + "Issue 2", + "https://github.com/user/repo/issues/2", + None, + timedelta(days=4), + ), + IssueWithMetrics( + "Issue 3", "https://github.com/user/repo/issues/3", None, None + ), + ] + + # Call the function and check the result + result = get_average_time_to_close(issues_with_metrics) + expected_result = timedelta(days=3) + self.assertEqual(result, expected_result) + + def test_get_average_time_to_close_no_issues(self): + """Test that the function returns None if there are no issues with time to close.""" + # Create mock data + issues_with_metrics = [ + IssueWithMetrics( + "Issue 1", "https://github.com/user/repo/issues/1", None, None + ), + IssueWithMetrics( + "Issue 2", "https://github.com/user/repo/issues/2", None, None + ), + IssueWithMetrics( + "Issue 3", "https://github.com/user/repo/issues/3", None, None + ), + ] + + # Call the function and check the result + result = get_average_time_to_close(issues_with_metrics) + expected_result = None + self.assertEqual(result, expected_result) class TestGetAverageTimeToFirstResponse(unittest.TestCase): @@ -170,77 +203,48 @@ def test_get_average_time_to_first_response(self): checks that the function returns the correct average time to first response. """ - # Create a list of mock GitHub issues with time to first response attributes - issues = [ - [ - "Title 1", - "https://github.com/owner/repo1", - timedelta(days=1, hours=2, minutes=30), - ], - [ - "Title 2", - "https://github.com/owner/repo2", - timedelta(days=3, hours=4, minutes=30), - ], - ["Title 3", "https://github.com/owner/repo3", timedelta(minutes=30)], - ] - - # Call get_average_time_to_first_response with the list of issues - average_time_to_first_response = get_average_time_to_first_response(issues) - - # Check that the function returns the correct average time to first response - expected_average_time_to_first_response = timedelta( - days=1, hours=10, minutes=30 - ) - self.assertEqual( - average_time_to_first_response, expected_average_time_to_first_response - ) - - -class TestGetNumberOfIssuesOpened(unittest.TestCase): - """Test case class for the get_number_of_issues_opened function in the issue_metrics module. - - This class contains test cases for the get_number_of_issues_opened function, - which calculates the number of open issues in a list of GitHub issues. - - """ - - def test_get_number_of_issues_opened(self): - """Test that get_number_of_issues_opened returns the correct number of open issues.""" - # Set up the mock issues - mock_issues = [ - MagicMock(state="open"), - MagicMock(state="closed"), - MagicMock(state="open"), + # Create mock data + issues_with_metrics = [ + IssueWithMetrics( + "Issue 1", "https://github.com/user/repo/issues/1", timedelta(days=1) + ), + IssueWithMetrics( + "Issue 2", "https://github.com/user/repo/issues/2", timedelta(days=2) + ), + IssueWithMetrics("Issue 3", "https://github.com/user/repo/issues/3", None), ] - # Call get_number_of_issues_opened and check that it returns the - # correct number of open issues - num_issues_opened = issue_metrics.get_number_of_issues_open(mock_issues) - self.assertEqual(num_issues_opened, 2) + # Call the function and check the result + result = get_average_time_to_first_response(issues_with_metrics) + expected_result = timedelta(days=1.5) + self.assertEqual(result, expected_result) -class TestGetNumberOfIssuesClosed(unittest.TestCase): - """Test case class for the get_number_of_issues_closeded function in the issue_metrics module. +class TestMeasureTimeToClose(unittest.TestCase): + """Test suite for the measure_time_to_close function.""" - This class contains test cases for the get_number_of_issues_closed function, - which calculates the number of open issues in a list of GitHub issues. + def test_measure_time_to_close(self): + """Test that the function correctly measures the time to close an issue.""" + # Create a mock issue object + issue = MagicMock() + issue.state = "closed" + issue.created_at = "2021-01-01T00:00:00Z" + issue.closed_at = "2021-01-03T00:00:00Z" - """ + # Call the function and check the result + result = measure_time_to_close(issue) + expected_result = timedelta(days=2) + self.assertEqual(result, expected_result) - def test_get_number_of_issues_closed(self): - """Test that get_number_of_issues_closed returns the correct number of closed issues.""" - # Set up the mock issues - mock_issues = [ - MagicMock(state="open"), - MagicMock(state="closed"), - MagicMock(state="open"), - ] + def test_measure_time_to_close_raises_error(self): + """Test that the function raises a ValueError if the issue is not closed.""" + # Create a mock issue object + issue = MagicMock() + issue.state = "open" - # Call get_number_of_issues_opened and check that it returns the - # correct number of open issues - num_issues_closed = issue_metrics.get_number_of_issues_closed(mock_issues) - self.assertEqual(num_issues_closed, 1) + # Call the function and check that it raises a ValueError + with self.assertRaises(ValueError): + measure_time_to_close(issue) class TestWriteToMarkdown(unittest.TestCase): @@ -251,36 +255,37 @@ def test_write_to_markdown(self): This test creates a list of mock GitHub issues with time to first response attributes, calls write_to_markdown with the list and the average time to - first response, and checks that the function writes the correct markdown - file. + first response, time to close and checks that the function writes the correct + markdown file. """ - # Create a list of mock GitHub issues with time to first response attributes + # Create mock data issues_with_metrics = [ - ( + IssueWithMetrics( "Issue 1", "https://github.com/user/repo/issues/1", - timedelta(days=1, hours=2, minutes=30), + timedelta(days=1), + timedelta(days=2), ), - ( + IssueWithMetrics( "Issue 2", "https://github.com/user/repo/issues/2", - timedelta(days=3, hours=4, minutes=30), + timedelta(days=3), + timedelta(days=4), ), - ("Issue 3", "https://github.com/user/repo/issues/3", timedelta(minutes=30)), ] + average_time_to_first_response = timedelta(days=2) + average_time_to_close = timedelta(days=3) + num_issues_opened = 2 + num_issues_closed = 1 - # Call write_to_markdown with the list of issues and the average time to first response - average_time_to_first_response = timedelta(days=1, hours=3, minutes=10) - num_issues_open = 1 - num_issues_closed = 2 - + # Call the function write_to_markdown( issues_with_metrics, average_time_to_first_response, - num_issues_open, + average_time_to_close, + num_issues_opened, num_issues_closed, - file=None, ) # Check that the function writes the correct markdown file @@ -288,17 +293,70 @@ def test_write_to_markdown(self): content = file.read() expected_content = ( "# Issue Metrics\n\n" - "Average time to first response: 1 day, 3:10:00\n" - "Number of issues that remain open: 1\n" - "Number of issues closed: 2\n" - "Total number of issues created: 3\n\n" - "| Title | URL | TTFR |\n" - "| --- | --- | ---: |\n" - "| Issue 3 | https://github.com/user/repo/issues/3 | 0:30:00 |\n" - "| Issue 2 | https://github.com/user/repo/issues/2 | 3 days, 4:30:00 |\n" - "| Issue 1 | https://github.com/user/repo/issues/1 | 1 day, 2:30:00 |\n" + "| Metric | Value |\n" + "| --- | ---: |\n" + "| Average time to first response | 2 days, 0:00:00 |\n" + "| Average time to close | 3 days, 0:00:00 |\n" + "| Number of issues that remain open | 2 |\n" + "| Number of issues closed | 1 |\n" + "| Total number of issues created | 2 |\n\n" + "| Title | URL | Time to first response | Time to close \n" + "| --- | --- | ---: | ---: |\n" + "| Issue 1 | https://github.com/user/repo/issues/1 | 1 day, 0:00:00 | " + "2 days, 0:00:00 |\n" + "| Issue 2 | https://github.com/user/repo/issues/2 | 3 days, 0:00:00 | " + "4 days, 0:00:00 |\n" ) self.assertEqual(content, expected_content) + os.remove("issue_metrics.md") + + def test_write_to_markdown_no_issues(self): + """Test that write_to_markdown writes the correct markdown file when no issues are found.""" + # Call the function with no issues + with patch("builtins.open", mock_open()) as mock_open_file: + write_to_markdown([], None, None, 0, 0) + + # Check that the file was written correctly + expected_output = "no issues found for the given search criteria\n\n" + mock_open_file.assert_called_once_with( + "issue_metrics.md", "w", encoding="utf-8" + ) + mock_open_file().write.assert_called_once_with(expected_output) + + +class TestGetEnvVars(unittest.TestCase): + """Test suite for the get_env_vars function.""" + + def test_get_env_vars(self): + """Test that the function correctly retrieves the environment variables.""" + # Set the environment variables + os.environ["ISSUE_SEARCH_QUERY"] = "is:issue is:open" + os.environ["REPOSITORY_URL"] = "https://github.com/user/repo" + + # Call the function and check the result + result = get_env_vars() + expected_result = ("is:issue is:open", "https://github.com/user/repo") + self.assertEqual(result, expected_result) + + def test_get_env_vars_missing_query(self): + """Test that the function raises a ValueError + if the ISSUE_SEARCH_QUERY environment variable is not set.""" + # Unset the ISSUE_SEARCH_QUERY environment variable + os.environ.pop("ISSUE_SEARCH_QUERY", None) + + # Call the function and check that it raises a ValueError + with self.assertRaises(ValueError): + get_env_vars() + + def test_get_env_vars_missing_url(self): + """Test that the function raises a ValueError if the + REPOSITORY_URL environment variable is not set.""" + # Unset the REPOSITORY_URL environment variable + os.environ.pop("REPOSITORY_URL", None) + + # Call the function and check that it raises a ValueError + with self.assertRaises(ValueError): + get_env_vars() class TestMain(unittest.TestCase): @@ -403,7 +461,7 @@ def test_main_no_issues_found( # Call main and check that it writes 'No issues found' issue_metrics.main() - mock_write_to_markdown.assert_called_once_with(None, None, None, None) + mock_write_to_markdown.assert_called_once_with(None, None, None, None, None) if __name__ == "__main__":