diff --git a/README.md b/README.md index c60d65b..46b9cb3 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,12 @@ jobs: | @zkoppert | 1913 | False | [Sponsor Link](https://github.com/sponsors/zkoppert) | [super-linter/super-linter](https://github.com/super-linter/super-linter/commits?author=zkoppert&since=2021-09-01&until=2023-09-30) | ``` +## GitHub Actions Job Summary + +When running as a GitHub Action, the contributors report is automatically displayed in the [GitHub Actions Job Summary](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary). This provides immediate visibility of the results directly in the workflow run interface without needing to check separate files or issues. + +The job summary contains the same markdown content that is written to the `contributors.md` file, making it easy to view contributor information right in the GitHub Actions UI. + ## Local usage without Docker 1. Make sure you have at least Python3.11 installed diff --git a/markdown.py b/markdown.py index 5d5e419..ab3d852 100644 --- a/markdown.py +++ b/markdown.py @@ -1,6 +1,8 @@ # pylint: disable=too-many-locals """This module contains the functions needed to write the output to markdown files.""" +import os + def write_to_markdown( collaborators, @@ -14,19 +16,30 @@ def write_to_markdown( ghe, ): """ - This function writes a list of collaborators to a markdown file in table format. - Each collaborator is represented as a dictionary with keys 'username', 'contribution_count', 'new_contributor', and 'commits'. + This function writes a list of collaborators to a markdown file in table format + and optionally to GitHub Actions Job Summary if running in a GitHub Actions environment. + Each collaborator is represented as a dictionary with keys 'username', + 'contribution_count', 'new_contributor', and 'commits'. Args: - collaborators (list): A list of dictionaries, where each dictionary represents a collaborator. - Each dictionary should have the keys 'username', 'contribution_count', and 'commits'. - filename (str): The path of the markdown file to which the table will be written. - start_date (str): The start date of the date range for the contributor list. + collaborators (list): A list of dictionaries, where each dictionary + represents a collaborator. Each dictionary should + have the keys 'username', 'contribution_count', + and 'commits'. + filename (str): The path of the markdown file to which the table will + be written. + start_date (str): The start date of the date range for the contributor + list. end_date (str): The end date of the date range for the contributor list. - organization (str): The organization for which the contributors are being listed. - repository (str): The repository for which the contributors are being listed. - sponsor_info (str): True if the user wants the sponsor_url shown in the report - link_to_profile (str): True if the user wants the username linked to Github profile in the report + organization (str): The organization for which the contributors are + being listed. + repository (str): The repository for which the contributors are being + listed. + sponsor_info (str): True if the user wants the sponsor_url shown in + the report + link_to_profile (str): True if the user wants the username linked to + Github profile in the report + ghe (str): The GitHub Enterprise instance URL, if applicable. Returns: None @@ -44,52 +57,98 @@ def write_to_markdown( ghe, ) - # Put together the summary table including # of new contributions, # of new contributors, % new contributors, % returning contributors + # Put together the summary table including # of new contributions, + # # of new contributors, % new contributors, % returning contributors summary_table = get_summary_table( collaborators, start_date, end_date, total_contributions ) - # Write the markdown file - write_markdown_file( - filename, start_date, end_date, organization, repository, table, summary_table + # Generate the markdown content once + content = generate_markdown_content( + start_date, end_date, organization, repository, table, summary_table ) + # Write the markdown file + write_markdown_file(filename, content) + + # Also write to GitHub Actions Step Summary if available + write_to_github_summary(content) + + +def write_to_github_summary(content): + """ + Write markdown content to GitHub Actions Step Summary. + + Args: + content (str): The pre-generated markdown content to write. + + Returns: + None + """ + # Only write to GitHub Step Summary if we're running in a GitHub Actions + # environment + github_step_summary = os.environ.get("GITHUB_STEP_SUMMARY") + if github_step_summary: + with open(github_step_summary, "a", encoding="utf-8") as summary_file: + summary_file.write(content) + -def write_markdown_file( - filename, start_date, end_date, organization, repository, table, summary_table +def generate_markdown_content( + start_date, end_date, organization, repository, table, summary_table ): """ - This function writes all the tables and data to a markdown file with tables to organizae the information. + This function generates markdown content as a string. Args: - filename (str): The path of the markdown file to which the table will be written. - start_date (str): The start date of the date range for the contributor list. + start_date (str): The start date of the date range for the contributor + list. end_date (str): The end date of the date range for the contributor list. - organization (str): The organization for which the contributors are being listed. - repository (str): The repository for which the contributors are being listed. - table (str): A string containing a markdown table of the contributors and the total contribution count. - summary_table (str): A string containing a markdown table of the summary statistics. + organization (str): The organization for which the contributors are + being listed. + repository (str): The repository for which the contributors are being + listed. + table (str): A string containing a markdown table of the contributors + and the total contribution count. + summary_table (str): A string containing a markdown table of the + summary statistics. + + Returns: + str: The complete markdown content as a string. + + """ + content = "# Contributors\n\n" + if start_date and end_date: + content += f"- Date range for contributor list: {start_date} to {end_date}\n" + if organization: + content += f"- Organization: {organization}\n" + if repository: + content += f"- Repository: {repository}\n" + content += "\n" + content += summary_table + content += table + content += ( + "\n _this file was generated by the " + "[Contributors GitHub Action]" + "(https://github.com/github/contributors)_\n" + ) + return content + + +def write_markdown_file(filename, content): + """ + This function writes the pre-generated markdown content to a file. + + Args: + filename (str): The path of the markdown file to which the content will + be written. + content (str): The pre-generated markdown content to write. Returns: None """ with open(filename, "w", encoding="utf-8") as markdown_file: - markdown_file.write("# Contributors\n\n") - if start_date and end_date: - markdown_file.write( - f"- Date range for contributor list: {start_date} to {end_date}\n" - ) - if organization: - markdown_file.write(f"- Organization: {organization}\n") - if repository: - markdown_file.write(f"- Repository: {repository}\n") - markdown_file.write("\n") - markdown_file.write(summary_table) - markdown_file.write(table) - markdown_file.write( - "\n _this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_\n" - ) + markdown_file.write(content) def get_summary_table(collaborators, start_date, end_date, total_contributions): diff --git a/test_markdown.py b/test_markdown.py index 54a7337..98c5e1a 100644 --- a/test_markdown.py +++ b/test_markdown.py @@ -12,8 +12,13 @@ class TestMarkdown(unittest.TestCase): Test case for the markdown module. """ + @patch( + "markdown.os.environ.get", return_value=None + ) # Mock GITHUB_STEP_SUMMARY to None @patch("builtins.open", new_callable=mock_open) - def test_write_to_markdown(self, mock_file): + def test_write_to_markdown( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument """ Test the write_to_markdown function. """ @@ -54,22 +59,32 @@ def test_write_to_markdown(self, mock_file): ) mock_file.assert_called_once_with("filename", "w", encoding="utf-8") - mock_file().write.assert_any_call("# Contributors\n\n") - mock_file().write.assert_any_call( + # With the new implementation, content is written as a single string + expected_content = ( + "# Contributors\n\n" "- Date range for contributor list: 2023-01-01 to 2023-01-02\n" - ) - mock_file().write.assert_any_call( - "| Total Contributors | Total Contributions | % New Contributors |\n| --- | --- | --- |\n| 2 | 300 | 50.0% |\n\n" - ) - mock_file().write.assert_any_call( - "| Username | All Time Contribution Count | New Contributor | Commits between 2023-01-01 and 2023-01-02 |\n" + "- Repository: org/repo\n\n" + "| Total Contributors | Total Contributions | % New Contributors |\n" + "| --- | --- | --- |\n" + "| 2 | 300 | 50.0% |\n\n" + "| Username | All Time Contribution Count | New Contributor | " + "Commits between 2023-01-01 and 2023-01-02 |\n" "| --- | --- | --- | --- |\n" "| @user1 | 100 | False | commit url |\n" "| @user2 | 200 | True | commit url2 |\n" + "\n _this file was generated by the " + "[Contributors GitHub Action]" + "(https://github.com/github/contributors)_\n" ) + mock_file().write.assert_called_once_with(expected_content) + @patch( + "markdown.os.environ.get", return_value=None + ) # Mock GITHUB_STEP_SUMMARY to None @patch("builtins.open", new_callable=mock_open) - def test_write_to_markdown_with_sponsors(self, mock_file): + def test_write_to_markdown_with_sponsors( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument """ Test the write_to_markdown function with sponsors info turned on. """ @@ -110,22 +125,32 @@ def test_write_to_markdown_with_sponsors(self, mock_file): ) mock_file.assert_called_once_with("filename", "w", encoding="utf-8") - mock_file().write.assert_any_call("# Contributors\n\n") - mock_file().write.assert_any_call( + # With the new implementation, content is written as a single string + expected_content = ( + "# Contributors\n\n" "- Date range for contributor list: 2023-01-01 to 2023-01-02\n" - ) - mock_file().write.assert_any_call( - "| Total Contributors | Total Contributions | % New Contributors |\n| --- | --- | --- |\n| 2 | 300 | 50.0% |\n\n" - ) - mock_file().write.assert_any_call( - "| Username | All Time Contribution Count | New Contributor | Sponsor URL | Commits between 2023-01-01 and 2023-01-02 |\n" + "- Repository: org/repo\n\n" + "| Total Contributors | Total Contributions | % New Contributors |\n" + "| --- | --- | --- |\n" + "| 2 | 300 | 50.0% |\n\n" + "| Username | All Time Contribution Count | New Contributor | " + "Sponsor URL | Commits between 2023-01-01 and 2023-01-02 |\n" "| --- | --- | --- | --- | --- |\n" "| @user1 | 100 | False | [Sponsor Link](sponsor_url_1) | commit url |\n" "| @user2 | 200 | True | not sponsorable | commit url2 |\n" + "\n _this file was generated by the " + "[Contributors GitHub Action]" + "(https://github.com/github/contributors)_\n" ) + mock_file().write.assert_called_once_with(expected_content) + @patch( + "markdown.os.environ.get", return_value=None + ) # Mock GITHUB_STEP_SUMMARY to None @patch("builtins.open", new_callable=mock_open) - def test_write_to_markdown_without_link_to_profile(self, mock_file): + def test_write_to_markdown_without_link_to_profile( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument """ Test the write_to_markdown function with link to profile turned off. """ @@ -166,19 +191,311 @@ def test_write_to_markdown_without_link_to_profile(self, mock_file): ) mock_file.assert_called_once_with("filename", "w", encoding="utf-8") - mock_file().write.assert_any_call("# Contributors\n\n") - mock_file().write.assert_any_call( + # With the new implementation, content is written as a single string + expected_content = ( + "# Contributors\n\n" "- Date range for contributor list: 2023-01-01 to 2023-01-02\n" - ) - mock_file().write.assert_any_call( - "| Total Contributors | Total Contributions | % New Contributors |\n| --- | --- | --- |\n| 2 | 300 | 50.0% |\n\n" - ) - mock_file().write.assert_any_call( - "| Username | All Time Contribution Count | New Contributor | Commits between 2023-01-01 and 2023-01-02 |\n" + "- Repository: org/repo\n\n" + "| Total Contributors | Total Contributions | % New Contributors |\n" + "| --- | --- | --- |\n" + "| 2 | 300 | 50.0% |\n\n" + "| Username | All Time Contribution Count | New Contributor | " + "Commits between 2023-01-01 and 2023-01-02 |\n" "| --- | --- | --- | --- |\n" "| user1 | 100 | False | commit url |\n" "| user2 | 200 | True | commit url2 |\n" + "\n _this file was generated by the " + "[Contributors GitHub Action]" + "(https://github.com/github/contributors)_\n" + ) + mock_file().write.assert_called_once_with(expected_content) + + @patch("markdown.os.environ.get", return_value="/tmp/step_summary") + @patch("builtins.open", new_callable=mock_open) + def test_write_to_github_summary( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument + """ + Test that markdown content is written to GitHub Step Summary + when environment variable is set. + """ + person1 = contributor_stats.ContributorStats( + "user1", + False, + "url", + 100, + "commit url", + "sponsor_url_1", + ) + person2 = contributor_stats.ContributorStats( + "user2", + False, + "url2", + 200, + "commit url2", + "sponsor_url_2", + ) + # Set person2 as a new contributor since this cannot be set on initiatization of the object + person2.new_contributor = True + collaborators = [ + person1, + person2, + ] + ghe = "" + + write_to_markdown( + collaborators, + "filename", + "2023-01-01", + "2023-01-02", + None, + "org/repo", + "false", + True, + ghe, + ) + + # Verify that open was called twice - once for the markdown file + # and once for the step summary + self.assertEqual(mock_file.call_count, 2) + + # Verify the first call is for the markdown file + first_call = mock_file.call_args_list[0] + self.assertEqual(first_call[0], ("filename", "w")) + self.assertEqual(first_call[1]["encoding"], "utf-8") + + # Verify the second call is for the step summary file + second_call = mock_file.call_args_list[1] + self.assertEqual(second_call[0], ("/tmp/step_summary", "a")) + self.assertEqual(second_call[1]["encoding"], "utf-8") + + @patch( + "markdown.os.environ.get", return_value=None + ) # Mock GITHUB_STEP_SUMMARY to None + @patch("builtins.open", new_callable=mock_open) + def test_write_to_markdown_with_organization( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument + """ + Test the write_to_markdown function with organization specified. + """ + person1 = contributor_stats.ContributorStats( + "user1", + False, + "url", + 100, + "https://github.com/org1/repo1/commits?author=user1", + "sponsor_url_1", + ) + person2 = contributor_stats.ContributorStats( + "user2", + False, + "url2", + 200, + "https://github.com/org2/repo2/commits?author=user2, " + "https://github.com/org3/repo3/commits?author=user2", + "sponsor_url_2", + ) + # Set person2 as a new contributor since this cannot be set on initiatization of the object + person2.new_contributor = True + collaborators = [ + person1, + person2, + ] + ghe = "" + + write_to_markdown( + collaborators, + "filename", + "2023-01-01", + "2023-01-02", + "test-org", + None, + "false", + True, + ghe, + ) + + mock_file.assert_called_once_with("filename", "w", encoding="utf-8") + # With the new implementation, content is written as a single string + expected_content = ( + "# Contributors\n\n" + "- Date range for contributor list: 2023-01-01 to 2023-01-02\n" + "- Organization: test-org\n\n" + "| Total Contributors | Total Contributions | % New Contributors |\n" + "| --- | --- | --- |\n" + "| 2 | 300 | 50.0% |\n\n" + "| Username | All Time Contribution Count | New Contributor | " + "Commits between 2023-01-01 and 2023-01-02 |\n" + "| --- | --- | --- | --- |\n" + "| @user1 | 100 | False | " + "[org1/repo1](https://github.com/org1/repo1/commits?author=user1), |\n" + "| @user2 | 200 | True | " + "[org2/repo2](https://github.com/org2/repo2/commits?author=user2), " + "[org3/repo3](https://github.com/org3/repo3/commits?author=user2), |\n" + "\n _this file was generated by the " + "[Contributors GitHub Action]" + "(https://github.com/github/contributors)_\n" + ) + mock_file().write.assert_called_once_with(expected_content) + + @patch( + "markdown.os.environ.get", return_value=None + ) # Mock GITHUB_STEP_SUMMARY to None + @patch("builtins.open", new_callable=mock_open) + def test_write_to_markdown_empty_collaborators( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument + """ + Test the write_to_markdown function with empty collaborators list. + """ + collaborators = [] + ghe = "" + + write_to_markdown( + collaborators, + "filename", + "2023-01-01", + "2023-01-02", + None, + "org/repo", + "false", + True, + ghe, + ) + + mock_file.assert_called_once_with("filename", "w", encoding="utf-8") + # With the new implementation, content is written as a single string + expected_content = ( + "# Contributors\n\n" + "- Date range for contributor list: 2023-01-01 to 2023-01-02\n" + "- Repository: org/repo\n\n" + "| Total Contributors | Total Contributions | % New Contributors |\n" + "| --- | --- | --- |\n" + "| 0 | 0 | 0% |\n\n" + "| Username | All Time Contribution Count | New Contributor | " + "Commits between 2023-01-01 and 2023-01-02 |\n" + "| --- | --- | --- | --- |\n" + "\n _this file was generated by the " + "[Contributors GitHub Action]" + "(https://github.com/github/contributors)_\n" + ) + mock_file().write.assert_called_once_with(expected_content) + + @patch( + "markdown.os.environ.get", return_value=None + ) # Mock GITHUB_STEP_SUMMARY to None + @patch("builtins.open", new_callable=mock_open) + def test_write_to_markdown_no_dates( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument + """ + Test the write_to_markdown function without start and end dates. + """ + person1 = contributor_stats.ContributorStats( + "user1", + False, + "url", + 100, + "commit url", + "sponsor_url_1", + ) + person2 = contributor_stats.ContributorStats( + "user2", + False, + "url2", + 200, + "commit url2", + "sponsor_url_2", + ) + collaborators = [ + person1, + person2, + ] + ghe = "" + + write_to_markdown( + collaborators, + "filename", + None, + None, + None, + "org/repo", + "false", + True, + ghe, + ) + + mock_file.assert_called_once_with("filename", "w", encoding="utf-8") + # With the new implementation, content is written as a single string + expected_content = ( + "# Contributors\n\n" + "- Repository: org/repo\n\n" + "| Total Contributors | Total Contributions |\n" + "| --- | --- |\n" + "| 2 | 300 |\n\n" + "| Username | All Time Contribution Count | All Commits |\n" + "| --- | --- | --- |\n" + "| @user1 | 100 | commit url |\n" + "| @user2 | 200 | commit url2 |\n" + "\n _this file was generated by the " + "[Contributors GitHub Action]" + "(https://github.com/github/contributors)_\n" + ) + mock_file().write.assert_called_once_with(expected_content) + + @patch( + "markdown.os.environ.get", return_value=None + ) # Mock GITHUB_STEP_SUMMARY to None + @patch("builtins.open", new_callable=mock_open) + def test_write_to_markdown_with_ghe( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument + """ + Test the write_to_markdown function with GitHub Enterprise URL. + """ + person1 = contributor_stats.ContributorStats( + "user1", + False, + "url", + 100, + "https://github.example.com/org1/repo1/commits?author=user1", + "sponsor_url_1", + ) + collaborators = [person1] + ghe = "https://github.example.com" + + write_to_markdown( + collaborators, + "filename", + "2023-01-01", + "2023-01-02", + "test-org", + None, + "false", + True, + ghe, + ) + + mock_file.assert_called_once_with("filename", "w", encoding="utf-8") + # With the new implementation, content is written as a single string + expected_content = ( + "# Contributors\n\n" + "- Date range for contributor list: 2023-01-01 to 2023-01-02\n" + "- Organization: test-org\n\n" + "| Total Contributors | Total Contributions | % New Contributors |\n" + "| --- | --- | --- |\n" + "| 1 | 100 | 0.0% |\n\n" + "| Username | All Time Contribution Count | New Contributor | " + "Commits between 2023-01-01 and 2023-01-02 |\n" + "| --- | --- | --- | --- |\n" + "| @user1 | 100 | False | " + "[org1/repo1](https://github.example.com/org1/repo1/commits?author=user1), |\n" + "\n _this file was generated by the " + "[Contributors GitHub Action]" + "(https://github.com/github/contributors)_\n" ) + mock_file().write.assert_called_once_with(expected_content) if __name__ == "__main__":