diff --git a/README.md b/README.md index e98a7a1..6426370 100644 --- a/README.md +++ b/README.md @@ -95,16 +95,17 @@ This action can be configured to authenticate with GitHub App Installation or Pe #### Other Configuration Options -| field | required | default | description | -| ------------------- | ----------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GH_ENTERPRISE_URL` | False | "" | The `GH_ENTERPRISE_URL` is used to connect to an enterprise server instance of GitHub. github.com users should not enter anything here. | -| `ORGANIZATION` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the GitHub organization which you want the contributor information of all repos from. ie. github.com/github would be `github` | -| `REPOSITORY` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the repository and organization which you want the contributor information from. ie. `github-community-projects/contributors` or a comma separated list of multiple repositories `github/contributor,super-linter/super-linter` | -| `START_DATE` | False | Beginning of time | The date from which you want to start gathering contributor information. ie. Aug 1st, 2023 would be `2023-08-01`. | -| `END_DATE` | False | Current Date | The date at which you want to stop gathering contributor information. Must be later than the `START_DATE`. ie. Aug 2nd, 2023 would be `2023-08-02` | -| `SPONSOR_INFO` | False | False | If you want to include sponsor information in the output. This will include the sponsor count and the sponsor URL. This will impact action performance. ie. SPONSOR_INFO = "False" or SPONSOR_INFO = "True" | -| `LINK_TO_PROFILE` | False | True | If you want to link usernames to their GitHub profiles in the output. ie. LINK_TO_PROFILE = "True" or LINK_TO_PROFILE = "False" | -| `OUTPUT_FILENAME` | False | contributors.md | The output filename for the markdown report. ie. OUTPUT_FILENAME = "my-report.md" | +| field | required | default | description | +| ------------------- | ----------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GH_ENTERPRISE_URL` | False | "" | The `GH_ENTERPRISE_URL` is used to connect to an enterprise server instance of GitHub. github.com users should not enter anything here. | +| `ORGANIZATION` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the GitHub organization which you want the contributor information of all repos from. ie. github.com/github would be `github` | +| `REPOSITORY` | Required to have `ORGANIZATION` or `REPOSITORY` | | The name of the repository and organization which you want the contributor information from. ie. `github/contributors` or a comma separated list of multiple repositories `github/contributor,super-linter/super-linter` | +| `START_DATE` | False | Beginning of time | The date from which you want to start gathering contributor information. ie. Aug 1st, 2023 would be `2023-08-01`. | +| `END_DATE` | False | Current Date | The date at which you want to stop gathering contributor information. Must be later than the `START_DATE`. ie. Aug 2nd, 2023 would be `2023-08-02` | +| `SPONSOR_INFO` | False | False | If you want to include sponsor information in the output. This will include the sponsor count and the sponsor URL. This will impact action performance. ie. SPONSOR_INFO = "False" or SPONSOR_INFO = "True" | +| `LINK_TO_PROFILE` | False | True | If you want to link usernames to their GitHub profiles in the output. ie. LINK_TO_PROFILE = "True" or LINK_TO_PROFILE = "False" | +| `OUTPUT_FILENAME` | False | contributors.md | The output filename for the markdown report. ie. OUTPUT_FILENAME = "my-report.md" | +| `SHOW_AVATAR` | False | False | If you want to show profile images in the markdown output. ie. SHOW_AVATAR = "True" or SHOW_AVATAR = "False" | **Note**: If `start_date` and `end_date` are specified then the action will determine if the contributor is new. A new contributor is one that has contributed in the date range specified but not before the start date. diff --git a/contributors.py b/contributors.py index 3a027c8..2885f92 100644 --- a/contributors.py +++ b/contributors.py @@ -28,6 +28,7 @@ def main(): sponsor_info, link_to_profile, output_filename, + show_avatar, ) = env.get_env_vars() # Auth to GitHub.com @@ -84,6 +85,7 @@ def main(): sponsor_info, link_to_profile, ghe, + show_avatar, ) json_writer.write_to_json( filename="contributors.json", diff --git a/env.py b/env.py index c66b75f..8b7bf6c 100644 --- a/env.py +++ b/env.py @@ -101,6 +101,27 @@ def validate_date_range(start_date: str, end_date: str) -> None: ) +def validate_output_filename(output_filename: str) -> str: + """Validate OUTPUT_FILENAME and return a safe filename.""" + if not output_filename: + return "contributors.md" + + filename = output_filename.strip() + if not filename: + return "contributors.md" + + if os.path.isabs(filename): + raise ValueError("OUTPUT_FILENAME must be a filename only, not a path") + + if "/" in filename or "\\" in filename: + raise ValueError("OUTPUT_FILENAME must be a filename only, not a path") + + if not re.fullmatch(r"[A-Za-z0-9._-]+", filename): + raise ValueError("OUTPUT_FILENAME contains invalid characters") + + return filename + + def get_env_vars( test: bool = False, ) -> tuple[ @@ -117,6 +138,7 @@ def get_env_vars( bool, bool, str, + bool, ]: """ Get the environment variables for use in the action. @@ -137,7 +159,6 @@ def get_env_vars( end_date (str): The end date to get contributor information to sponsor_info (str): Whether to get sponsor information on the contributor link_to_profile (str): Whether to link username to Github profile in markdown output - output_filename (str): The output filename for the markdown report """ if not test: @@ -179,16 +200,10 @@ def get_env_vars( sponsor_info = get_bool_env_var("SPONSOR_INFO", False) link_to_profile = get_bool_env_var("LINK_TO_PROFILE", False) - output_filename = os.getenv("OUTPUT_FILENAME", "").strip() or "contributors.md" - if not re.match(r"^[a-zA-Z0-9_\-\.]+$", output_filename): - raise ValueError( - "OUTPUT_FILENAME must contain only alphanumeric characters, " - "hyphens, underscores, and dots" - ) - if output_filename != os.path.basename(output_filename): - raise ValueError( - "OUTPUT_FILENAME must be a simple filename without path separators" - ) + output_filename = validate_output_filename( + os.getenv("OUTPUT_FILENAME", "contributors.md") + ) + show_avatar = get_bool_env_var("SHOW_AVATAR", False) # Separate repositories_str into a list based on the comma separator repositories_list = [] @@ -211,4 +226,5 @@ def get_env_vars( sponsor_info, link_to_profile, output_filename, + show_avatar, ) diff --git a/markdown.py b/markdown.py index f32adb3..88db305 100644 --- a/markdown.py +++ b/markdown.py @@ -4,6 +4,12 @@ import os +def _is_truthy(value) -> bool: + if isinstance(value, str): + return value.strip().lower() == "true" + return value is True + + def write_to_markdown( collaborators, filename, @@ -14,6 +20,7 @@ def write_to_markdown( sponsor_info, link_to_profile, ghe, + show_avatar=False, ): """ This function writes a list of collaborators to a markdown file in table format @@ -40,6 +47,8 @@ def write_to_markdown( 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. + show_avatar (str): True if the user wants to show profile images in + the report Returns: None @@ -55,6 +64,7 @@ def write_to_markdown( sponsor_info, link_to_profile, ghe, + show_avatar, ) # Put together the summary table including # of new contributions, @@ -196,6 +206,7 @@ def get_contributor_table( sponsor_info, link_to_profile, ghe, + show_avatar=False, ): """ This function returns a string containing a markdown table of the contributors and the total contribution count. @@ -209,16 +220,22 @@ def get_contributor_table( 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 + show_avatar (str): True if the user wants to show profile images in the report Returns: table (str): A string containing a markdown table of the contributors and the total contribution count. total_contributions (int): The total number of contributions made by all of the contributors. """ + sponsor_info = _is_truthy(sponsor_info) + show_avatar = _is_truthy(show_avatar) + link_to_profile = _is_truthy(link_to_profile) columns = ["Username", "All Time Contribution Count"] + if show_avatar: + columns.insert(0, "Avatar") if start_date and end_date: columns += ["New Contributor"] - if sponsor_info == "true": + if sponsor_info: columns += ["Sponsor URL"] if start_date and end_date: columns += [f"Commits between {start_date} and {end_date}"] @@ -250,8 +267,16 @@ def get_contributor_table( commit_urls += f"{url}, " new_contributor = collaborator.new_contributor - row = ( - f"| {'' if not link_to_profile else '@'}{username} | {contribution_count} |" + row = "| " + if show_avatar: + avatar_cell = ( + f'' + if collaborator.avatar_url + else "" + ) + row += f"{avatar_cell} | " + row += ( + f"{'' if not link_to_profile else '@'}{username} | {contribution_count} |" ) if "New Contributor" in columns: row += f" {new_contributor} |" diff --git a/test_contributors.py b/test_contributors.py index 0403516..19ba046 100644 --- a/test_contributors.py +++ b/test_contributors.py @@ -273,6 +273,7 @@ def test_main_runs_under_main_guard(self): False, False, "contributors.md", + False, ) mock_auth = MagicMock() @@ -346,6 +347,7 @@ def test_main_sets_new_contributor_flag(self): False, False, "contributors.md", + False, ) mock_auth_to_github.return_value = MagicMock() mock_get_all_contributors.side_effect = [[contributor], []] @@ -401,6 +403,7 @@ def test_main_fetches_sponsor_info_when_enabled(self): "true", False, "contributors.md", + False, ) mock_auth_to_github.return_value = MagicMock() mock_get_all_contributors.return_value = [contributor] diff --git a/test_env.py b/test_env.py index f9eabd0..6c0feb9 100644 --- a/test_env.py +++ b/test_env.py @@ -26,6 +26,7 @@ def setUp(self): "OUTPUT_FILENAME", "REPOSITORY", "START_DATE", + "SHOW_AVATAR", ] for key in env_keys: if key in os.environ: @@ -45,6 +46,7 @@ def setUp(self): "END_DATE": "2022-12-31", "SPONSOR_INFO": "False", "LINK_TO_PROFILE": "True", + "SHOW_AVATAR": "False", }, clear=True, ) @@ -66,7 +68,8 @@ def test_get_env_vars(self): end_date, sponsor_info, link_to_profile, - output_filename, + _output_filename, + _show_avatar, ) = env.get_env_vars() self.assertEqual(organization, "org") @@ -81,7 +84,6 @@ def test_get_env_vars(self): self.assertEqual(end_date, "2022-12-31") self.assertFalse(sponsor_info) self.assertTrue(link_to_profile) - self.assertEqual(output_filename, "contributors.md") @patch.dict( os.environ, @@ -97,6 +99,7 @@ def test_get_env_vars(self): "END_DATE": "2022-12-31", "SPONSOR_INFO": "False", "LINK_TO_PROFILE": "True", + "SHOW_AVATAR": "False", }, clear=True, ) @@ -125,6 +128,7 @@ def test_get_env_vars_missing_values(self): "END_DATE": "2022-12-31", "SPONSOR_INFO": "False", "LINK_TO_PROFILE": "True", + "SHOW_AVATAR": "False", }, clear=True, ) @@ -156,6 +160,7 @@ def test_get_env_vars_invalid_start_date(self): "END_DATE": "", "SPONSOR_INFO": "False", "LINK_TO_PROFILE": "True", + "SHOW_AVATAR": "False", }, clear=True, ) @@ -178,7 +183,8 @@ def test_get_env_vars_no_dates(self): end_date, sponsor_info, link_to_profile, - output_filename, + _output_filename, + _show_avatar, ) = env.get_env_vars() self.assertEqual(organization, "org") @@ -193,7 +199,6 @@ def test_get_env_vars_no_dates(self): self.assertEqual(end_date, "") self.assertFalse(sponsor_info) self.assertTrue(link_to_profile) - self.assertEqual(output_filename, "contributors.md") @patch.dict( os.environ, @@ -229,6 +234,7 @@ def test_get_env_vars_custom_output_filename(self): _sponsor_info, _link_to_profile, output_filename, + _show_avatar, ) = env.get_env_vars() self.assertEqual(output_filename, "custom-report.md") @@ -289,6 +295,80 @@ def test_get_env_vars_output_filename_special_chars_rejected(self): with self.assertRaises(ValueError): env.get_env_vars() + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "", + }, + clear=True, + ) + def test_get_env_vars_output_filename_empty_defaults(self): + """Test that empty OUTPUT_FILENAME defaults to contributors.md.""" + ( + _organization, + _repository_list, + _gh_app_id, + _gh_app_installation_id, + _gh_app_private_key, + _gh_app_enterprise_only, + _token, + _ghe, + _start_date, + _end_date, + _sponsor_info, + _link_to_profile, + output_filename, + _show_avatar, + ) = env.get_env_vars() + + self.assertEqual(output_filename, "contributors.md") + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": " ", + }, + clear=True, + ) + def test_get_env_vars_output_filename_whitespace_defaults(self): + """Test that whitespace OUTPUT_FILENAME defaults to contributors.md.""" + ( + _organization, + _repository_list, + _gh_app_id, + _gh_app_installation_id, + _gh_app_private_key, + _gh_app_enterprise_only, + _token, + _ghe, + _start_date, + _end_date, + _sponsor_info, + _link_to_profile, + output_filename, + _show_avatar, + ) = env.get_env_vars() + + self.assertEqual(output_filename, "contributors.md") + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "bad@name.md", + }, + clear=True, + ) + def test_get_env_vars_output_filename_invalid_chars_rejected(self): + """Test that OUTPUT_FILENAME rejects invalid characters.""" + with self.assertRaises(ValueError): + env.get_env_vars() + @patch.dict(os.environ, {}, clear=True) def test_get_env_vars_missing_org_or_repo(self): """Test that an error is raised if required environment variables are not set""" @@ -390,6 +470,7 @@ def test_get_env_vars_valid_date_range(self): _sponsor_info, _link_to_profile, _output_filename, + _show_avatar, ) = env.get_env_vars() self.assertEqual(start_date, "2024-01-01") self.assertEqual(end_date, "2025-01-01") diff --git a/test_markdown.py b/test_markdown.py index 50a3161..88f21b2 100644 --- a/test_markdown.py +++ b/test_markdown.py @@ -38,7 +38,7 @@ def test_write_to_markdown( "commit url2", "sponsor_url_2", ) - # Set person2 as a new contributor since this cannot be set on initiatization of the object + # Set person2 as a new contributor since this cannot be set on initialization of the object person2.new_contributor = True collaborators = [ person1, @@ -104,7 +104,7 @@ def test_write_to_markdown_with_sponsors( "commit url2", "", ) - # Set person2 as a new contributor since this cannot be set on initiatization of the object + # Set person2 as a new contributor since this cannot be set on initialization of the object person2.new_contributor = True collaborators = [ person1, @@ -144,6 +144,75 @@ def test_write_to_markdown_with_sponsors( ) 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_avatars( + self, mock_file, mock_env_get + ): # pylint: disable=unused-argument + """ + Test the write_to_markdown function with avatar images turned on. + """ + person1 = contributor_stats.ContributorStats( + "user1", + False, + "https://avatars.example.com/user1.png", + 100, + "commit url", + "sponsor_url_1", + ) + person2 = contributor_stats.ContributorStats( + "user2", + False, + "https://avatars.example.com/user2.png", + 200, + "commit url2", + "sponsor_url_2", + ) + # Set person2 as a new contributor since this cannot be set on initialization 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, + show_avatar=True, + ) + + 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" + "| 2 | 300 | 50.0% |\n\n" + "| Avatar | 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-community-projects/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 @@ -170,7 +239,7 @@ def test_write_to_markdown_without_link_to_profile( "commit url2", "sponsor_url_2", ) - # Set person2 as a new contributor since this cannot be set on initiatization of the object + # Set person2 as a new contributor since this cannot be set on initialization of the object person2.new_contributor = True collaborators = [ person1, @@ -235,7 +304,7 @@ def test_write_to_github_summary( "commit url2", "sponsor_url_2", ) - # Set person2 as a new contributor since this cannot be set on initiatization of the object + # Set person2 as a new contributor since this cannot be set on initialization of the object person2.new_contributor = True collaborators = [ person1, @@ -296,7 +365,7 @@ def test_write_to_markdown_with_organization( "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 + # Set person2 as a new contributor since this cannot be set on initialization of the object person2.new_contributor = True collaborators = [ person1,