diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1a24999 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = test*.py \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 256c693..c6fd40b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,4 @@ +--- # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 40a1c25..b6307eb 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,3 +1,4 @@ +--- name-template: 'v$RESOLVED_VERSION' tag-template: 'v$RESOLVED_VERSION' template: | diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 5b27fd6..6accb46 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -1,3 +1,4 @@ +--- name: Release Drafter on: diff --git a/.gitignore b/.gitignore index a747bb1..28fbcfb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Output files +issue_metrics.md # Byte-compiled / optimized / DLL files __pycache__/ @@ -138,3 +139,6 @@ dmypy.json # Cython debug symbols cython_debug/ + +# Mac +.DS_Store diff --git a/Makefile b/Makefile index 5baf351..107c6d7 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,7 @@ +.PHONY: test test: - pytest -v --cov=. --cov-fail-under=80 + pytest -v --cov=. --cov-config=.coveragerc --cov-fail-under=80 --cov-report term-missing + +.PHONY: clean +clean: + rm -rf .pytest_cache .coverage __pycache__ diff --git a/README.md b/README.md index 45cb3a4..5101d2b 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,25 @@ jobs: ### Example stale_repos.md output -TODO +```markdown +# Issue Metrics + +Average time to first response: 2 days, 3:30:00 +Number of issues: 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 | + +``` ## Local usage without Docker 1. Copy `.env-example` to `.env` 1. Fill out the `.env` file with a _token_ from a user that has access to the organization to scan (listed below). Tokens should have admin:org or read:org access. -TODO: Make sure this is accurate +1. Fill out the `.env` file with the _repository_url_ of the repository to scan +1. Fill out the `.env` file with the _search_query_ to filter issues by 1. `pip install -r requirements.txt` 1. Run `python3 ./issue_metrics.py`, which will output issue metrics data diff --git a/issue_metrics.py b/issue_metrics.py index f076aa7..8f5a4d2 100644 --- a/issue_metrics.py +++ b/issue_metrics.py @@ -1,5 +1,22 @@ +"""A script for measuring time to first response 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. + +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. + write_to_markdown: Write the issues with metrics to a markdown file. + +""" + import os -from datetime import datetime +from datetime import datetime, timedelta from os.path import dirname, join from urllib.parse import urlparse @@ -13,24 +30,25 @@ def search_issues(repository_url, issue_search_query, github_connection): Args: repository_url (str): The URL of the repository to search in. + ie https://github.com/user/repo issue_search_query (str): The search query to use for finding issues. github_connection (github3.GitHub): A connection to the GitHub API. Returns: List[github3.issues.Issue]: A list of issues that match the search query. """ + print("Searching for issues...") # Parse the repository owner and name from the URL parsed_url = urlparse(repository_url) path = parsed_url.path.strip("/") - + print(f"parsing URL: {repository_url}") # Split the path into owner and repo owner, repo = path.split("/") - - # Get the repository object - repo = github_connection.repository(owner, repo) # type: ignore + print(f"owner: {owner}, repo: {repo}") # Search for issues that match the query - issues = repo.search_issues(issue_search_query) # type: ignore + full_query = f"repo:{owner}/{repo} {issue_search_query}" + issues = github_connection.search_issues(full_query) # type: ignore # Print the issue titles for issue in issues: @@ -59,8 +77,8 @@ def measure_time_to_first_response(issues): issues (list of github3.Issue): A list of GitHub issues. Returns: - list of github3.Issue: A list of GitHub issues with the time to first response - added as an attribute. + 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. @@ -69,22 +87,31 @@ def measure_time_to_first_response(issues): issues_with_metrics = [] for issue in issues: # Get the first comment - first_comment = issue.comments()[0] # type: ignore - - # Get the created_at time for the first comment - first_comment_time = datetime.fromisoformat(first_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 - - # Add the time to the issue - issue.time_to_first_response = time_to_first_response + 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) + issues_with_metrics.append( + [ + issue.title, + issue.html_url, + time_to_first_response, + ] + ) return issues_with_metrics @@ -97,7 +124,7 @@ def get_average_time_to_first_response(issues): first response added as an attribute. Returns: - datetime.timedelta: The average time to first response for the issues. + 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. @@ -105,13 +132,18 @@ def get_average_time_to_first_response(issues): """ total_time_to_first_response = 0 for issue in issues: - total_time_to_first_response += issue.time_to_first_response.total_seconds() + total_time_to_first_response += issue[2].total_seconds() - average_time_to_first_response = total_time_to_first_response / len( + average_seconds_to_first_response = total_time_to_first_response / len( issues ) # type: ignore - return average_time_to_first_response + # 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 timedelta(seconds=average_seconds_to_first_response) def write_to_markdown(issues_with_metrics, average_time_to_first_response, file=None): @@ -136,10 +168,10 @@ def write_to_markdown(issues_with_metrics, average_time_to_first_response, file= f"Average time to first response: {average_time_to_first_response}\n" ) file.write(f"Number of issues: {len(issues_with_metrics)}\n\n") - file.write("| Issue | TTFR |\n") - file.write("| --- | ---: |\n") - for issue, ttfr in issues_with_metrics: - file.write(f"| {issue} | {ttfr} |\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") print("Wrote issue metrics to issue_metrics.md") @@ -170,15 +202,12 @@ def main(): if not issue_search_query: raise ValueError("ISSUE_SEARCH_QUERY environment variable not set") - issue_search_query = os.getenv("REPOSITORY_URL") - if not issue_search_query: + repo_url = os.getenv("REPOSITORY_URL") + if not repo_url: raise ValueError("REPOSITORY_URL environment variable not set") # Search for issues - issues = search_issues(issue_search_query, issue_search_query, github_connection) - - # Print the number of issues found - print(f"Found {len(issues)} issues") + issues = search_issues(repo_url, issue_search_query, github_connection) # Find the time to first response issues_with_ttfr = measure_time_to_first_response(issues) @@ -186,9 +215,6 @@ def main(): issues_with_ttfr ) - # Print the average time to first response - print(f"Average time to first response: {average_time_to_first_response}") - # Write the results to a markdown file write_to_markdown(issues_with_ttfr, average_time_to_first_response) diff --git a/test_issue_metrics.py b/test_issue_metrics.py index bfb84ba..ade529f 100644 --- a/test_issue_metrics.py +++ b/test_issue_metrics.py @@ -1,22 +1,23 @@ -"""Unit tests for the issue-metrics script. +"""Unit tests for the issue_metrics module. -This module contains unit tests for the functions in the issue-metrics script. -The tests use the unittest module and the MagicMock and patch classes from the -unittest.mock module to mock the GitHub API and test the functions in isolation. +This module contains unit tests for the functions in the issue_metrics module. +The tests use the unittest module and the unittest.mock module to mock the +GitHub API and test the functions in isolation. Classes: - TestSearchIssues: Test the search_issues function. - TestAuthToGithub: Test the auth_to_github function. - TestMeasureTimeToFirstResponse: Test the measure_time_to_first_response function. - TestGetAverageTimeToFirstResponse: Test the get_average_time_to_first_response function. - TestWriteToMarkdown: Test the write_to_markdown function. - + TestSearchIssues: A class containing unit tests for the search_issues function. + TestMeasureTimeToFirstResponse: A class containing unit tests for the + measure_time_to_first_response function. + TestGetAverageTimeToFirstResponse: A class containing unit tests for the + get_average_time_to_first_response function. + TestWriteToMarkdown: A class containing unit tests for the write_to_markdown function. """ import os import unittest -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import MagicMock, patch +import issue_metrics from issue_metrics import ( auth_to_github, get_average_time_to_first_response, @@ -26,115 +27,37 @@ ) -class TestGetAverageTimeToFirstResponse(unittest.TestCase): - """Test the get_average_time_to_first_response function.""" - - def test_get_average_time_to_first_response(self): - """Test that get_average_time_to_first_response calculates the correct average. - - This test creates a list of mock GitHub issues with time to first response - attributes, calls get_average_time_to_first_response with the list, and - 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 = [ - MagicMock(time_to_first_response=timedelta(days=1)), - MagicMock(time_to_first_response=timedelta(hours=13, minutes=30)), - MagicMock(time_to_first_response=timedelta(hours=4, 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(hours=14) - self.assertEqual( - average_time_to_first_response, - expected_average_time_to_first_response.seconds, - ) - - -class TestWriteToMarkdown(unittest.TestCase): - """Test the write_to_markdown function.""" +class TestSearchIssues(unittest.TestCase): + """Unit tests for the search_issues function. - def test_write_to_markdown(self): - """Test that write_to_markdown writes the correct output to a file. + This class contains unit tests for the search_issues function in the + issue_metrics module. The tests use the unittest module and the unittest.mock + module to mock the GitHub API and test the function in isolation. - This test creates a temporary file, calls write_to_markdown with a list of - issues and their time to first response, and checks that the file contains - the expected output. + Methods: + test_search_issues: Test that search_issues returns the correct issues. - """ - # Create a temporary file - with open("temp.md", "w", encoding="utf-8") as file: - file.write("") + """ - # Call write_to_markdown with a list of issues and their time to first response - issues_with_metrics = [ - ("Issue 1", "1 day, 2:30:00"), - ("Issue 2", "3 days, 4:30:00"), - ("Issue 3", "0:30:00"), + @patch("issue_metrics.urlparse") + def test_search_issues(self, mock_urlparse): + """Test that search_issues returns the correct issues.""" + # Set up the mock GitHub connection object + mock_connection = MagicMock() + mock_issues = [ + MagicMock(title="Issue 1"), + MagicMock(title="Issue 2"), ] - average_time_to_first_response = "1 day, 3:10:00" - write_to_markdown( - issues_with_metrics, - average_time_to_first_response, - file=open("temp.md", "w", encoding="utf-8"), - ) - - # Check that the file contains the expected output - with open("temp.md", "r", encoding="utf-8") as file: - output = file.read() - expected_output = ( - "# Issue Metrics\n\n" - "Average time to first response: 1 day, 3:10:00\n" - "Number of issues: 3\n\n" - "| Issue | TTFR |\n" - "| --- | ---: |\n" - "| Issue 2 | 3 days, 4:30:00 |\n" - "| Issue 1 | 1 day, 2:30:00 |\n" - "| Issue 3 | 0:30:00 |\n" - ) - self.assertEqual(output, expected_output) - - # Remove the temporary file - os.remove("temp.md") - - -class TestSearchIssues(unittest.TestCase): - """Test the search_issues function.""" - - @patch("github3.login") - def test_search_issues(self, mock_login): - """Test that search_issues returns the correct issues. + mock_connection.search_issues.return_value = mock_issues - This test mocks the GitHub connection and repository, and checks that - search_issues returns the correct issues. + # Set up the mock urlparse function + mock_urlparse.return_value.path = "/user/repo" - """ - # Mock the GitHub connection - mock_gh = MagicMock() - mock_login.return_value = mock_gh - - # Mock the repository and issues - mock_repo = MagicMock() - mock_issue1 = MagicMock(title="Test issue 1") - mock_issue2 = MagicMock(title="Test issue 2") - mock_repo.search_issues.return_value = [mock_issue1, mock_issue2] - mock_gh.repository.return_value = mock_repo - - # Call the function + # Call search_issues and check that it returns the correct issues issues = search_issues( - "https://github.com/octocat/hello-world", "is:open is:issue", mock_gh + "https://github.com/user/repo", "is:open", mock_connection ) - - # Check the results - self.assertEqual(len(issues), 2) - self.assertEqual(issues[0].title, "Test issue 1") - self.assertEqual(issues[1].title, "Test issue 2") - mock_gh.repository.assert_called_once_with("octocat", "hello-world") - mock_repo.search_issues.assert_called_once_with("is:open is:issue") + self.assertEqual(issues, mock_issues) class TestAuthToGithub(unittest.TestCase): @@ -163,20 +86,6 @@ def test_auth_to_github_with_token(self, mock_login): self.assertEqual(github_connection, mock_gh) mock_login.assert_called_once_with(token="test_token") - def test_auth_to_github_without_token(self): - """Test that auth_to_github raises an exception without a token. - - This test sets the GH_TOKEN environment variable to an empty string and - checks that auth_to_github raises a ValueError exception. - - """ - # Set up the environment variable - os.environ.pop("GH_TOKEN", None) - - # Call the function and check for an exception - with self.assertRaises(ValueError): - auth_to_github() - class TestMeasureTimeToFirstResponse(unittest.TestCase): """Test the measure_time_to_first_response function.""" @@ -190,16 +99,20 @@ def test_measure_time_to_first_response(self): """ # Set up the mock GitHub issues mock_issue1 = MagicMock() + mock_issue1.comments = 1 mock_issue1.created_at = "2023-01-01T00:00:00Z" + mock_comment1 = MagicMock() - mock_comment1.created_at = "2023-01-02T00:00:00Z" - mock_issue1.comments.return_value = [mock_comment1] + 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 = "2023-01-04T00:00:00Z" - mock_issue2.comments.return_value = [mock_comment2] + 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] @@ -208,5 +121,166 @@ def test_measure_time_to_first_response(self): # Check the results self.assertEqual(len(issues_with_metrics), 2) - self.assertEqual(issues_with_metrics[0].time_to_first_response.days, 1) - self.assertEqual(issues_with_metrics[1].time_to_first_response.days, 1) + self.assertEqual(issues_with_metrics[0][2], timedelta(days=1)) + self.assertEqual(issues_with_metrics[1][2], timedelta(days=1)) + + +class TestGetAverageTimeToFirstResponse(unittest.TestCase): + """Test the get_average_time_to_first_response function.""" + + def test_get_average_time_to_first_response(self): + """Test that get_average_time_to_first_response calculates the correct average. + + This test creates a list of mock GitHub issues with time to first response + attributes, calls get_average_time_to_first_response with the list, and + 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 TestWriteToMarkdown(unittest.TestCase): + """Test the write_to_markdown function.""" + + def test_write_to_markdown(self): + """Test that write_to_markdown writes the correct markdown file. + + 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. + + """ + # Create a list of mock GitHub issues with time to first response attributes + issues_with_metrics = [ + ( + "Issue 1", + "https://github.com/user/repo/issues/1", + timedelta(days=1, hours=2, minutes=30), + ), + ( + "Issue 2", + "https://github.com/user/repo/issues/2", + timedelta(days=3, hours=4, minutes=30), + ), + ("Issue 3", "https://github.com/user/repo/issues/3", timedelta(minutes=30)), + ] + + # 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) + write_to_markdown( + issues_with_metrics, average_time_to_first_response, file=None + ) + + # Check that the function writes the correct markdown file + with open("issue_metrics.md", "r", encoding="utf-8") as file: + content = file.read() + expected_content = ( + "# Issue Metrics\n\n" + "Average time to first response: 1 day, 3:10:00\n" + "Number of issues: 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" + ) + self.assertEqual(content, expected_content) + + +class TestMain(unittest.TestCase): + """Unit tests for the main function. + + This class contains unit tests for the main function in the issue_metrics + module. The tests use the unittest module and the unittest.mock module to + mock the GitHub API and test the function in isolation. + + Methods: + test_main: Test that main runs without errors. + + """ + + @patch("issue_metrics.auth_to_github") + @patch("issue_metrics.search_issues") + @patch("issue_metrics.measure_time_to_first_response") + @patch("issue_metrics.get_average_time_to_first_response") + @patch.dict( + os.environ, + { + "ISSUE_SEARCH_QUERY": "is:open", + "REPOSITORY_URL": "https://github.com/user/repo", + }, + ) + def test_main( + self, + mock_get_average_time_to_first_response, + mock_measure_time_to_first_response, + mock_search_issues, + mock_auth_to_github, + ): + """Test that main runs without errors.""" + # Set up the mock GitHub connection object + mock_connection = MagicMock() + mock_auth_to_github.return_value = mock_connection + + # Set up the mock search_issues function + mock_issues = [ + MagicMock(title="Issue 1"), + MagicMock(title="Issue 2"), + ] + mock_search_issues.return_value = mock_issues + + # Set up the mock measure_time_to_first_response function + mock_issues_with_ttfr = [ + ( + "Issue 1", + "https://github.com/user/repo/issues/1", + timedelta(days=1, hours=2, minutes=30), + ), + ( + "Issue 2", + "https://github.com/user/repo/issues/2", + timedelta(days=3, hours=4, minutes=30), + ), + ] + mock_measure_time_to_first_response.return_value = mock_issues_with_ttfr + + # Set up the mock get_average_time_to_first_response function + mock_average_time_to_first_response = 15 + mock_get_average_time_to_first_response.return_value = ( + mock_average_time_to_first_response + ) + + # Call main and check that it runs without errors + issue_metrics.main() + + # Remove the markdown file created by main + os.remove("issue_metrics.md") + + +if __name__ == "__main__": + unittest.main()