diff --git a/README.md b/README.md index d59268b..176b9f7 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ This action can be configured to authenticate with GitHub App Installation or Pe | `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" | **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. @@ -119,6 +120,8 @@ jobs: runs-on: ubuntu-latest permissions: issues: write + env: + OUTPUT_FILENAME: contributors.md steps: - name: Get dates for last month @@ -148,7 +151,7 @@ jobs: with: title: Monthly contributor report token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./contributors.md + content-filepath: ./${{ env.OUTPUT_FILENAME }} assignees: ``` @@ -170,6 +173,8 @@ jobs: runs-on: ubuntu-latest permissions: issues: write + env: + OUTPUT_FILENAME: contributors.md steps: - name: Get dates for last month @@ -204,7 +209,7 @@ jobs: with: title: Monthly contributor report token: ${{ secrets.GITHUB_TOKEN }} - content-filepath: ./contributors.md + content-filepath: ./${{ env.OUTPUT_FILENAME }} assignees: ``` @@ -245,7 +250,7 @@ jobs: 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. +The job summary contains the same markdown content that is written to the configured output file (`contributors.md` by default), making it easy to view contributor information right in the GitHub Actions UI. ## Local usage without Docker diff --git a/contributors.py b/contributors.py index 96cdd86..3a027c8 100644 --- a/contributors.py +++ b/contributors.py @@ -27,6 +27,7 @@ def main(): end_date, sponsor_info, link_to_profile, + output_filename, ) = env.get_env_vars() # Auth to GitHub.com @@ -75,7 +76,7 @@ def main(): # print(contributors) markdown.write_to_markdown( contributors, - "contributors.md", + output_filename, start_date, end_date, organization, diff --git a/env.py b/env.py index 486f536..c66b75f 100644 --- a/env.py +++ b/env.py @@ -5,6 +5,7 @@ import datetime import os +import re from os.path import dirname, join from dotenv import load_dotenv @@ -115,6 +116,7 @@ def get_env_vars( str, bool, bool, + str, ]: """ Get the environment variables for use in the action. @@ -132,9 +134,10 @@ def get_env_vars( token (str): The GitHub token to use for authentication ghe (str): The GitHub Enterprise URL to use for authentication start_date (str): The start date to get contributor information from - end_date (str): The end date to get contributor information to. + 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: @@ -176,6 +179,16 @@ 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" + ) # Separate repositories_str into a list based on the comma separator repositories_list = [] @@ -197,4 +210,5 @@ def get_env_vars( end_date, sponsor_info, link_to_profile, + output_filename, ) diff --git a/test_contributors.py b/test_contributors.py index eb556f0..0403516 100644 --- a/test_contributors.py +++ b/test_contributors.py @@ -272,6 +272,7 @@ def test_main_runs_under_main_guard(self): "2022-12-31", False, False, + "contributors.md", ) mock_auth = MagicMock() @@ -344,6 +345,7 @@ def test_main_sets_new_contributor_flag(self): "2022-12-31", False, False, + "contributors.md", ) mock_auth_to_github.return_value = MagicMock() mock_get_all_contributors.side_effect = [[contributor], []] @@ -398,6 +400,7 @@ def test_main_fetches_sponsor_info_when_enabled(self): "", "true", False, + "contributors.md", ) 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 d01deb1..f9eabd0 100644 --- a/test_env.py +++ b/test_env.py @@ -23,6 +23,7 @@ def setUp(self): "GITHUB_APP_ENTERPRISE_ONLY", "GH_TOKEN", "ORGANIZATION", + "OUTPUT_FILENAME", "REPOSITORY", "START_DATE", ] @@ -65,6 +66,7 @@ def test_get_env_vars(self): end_date, sponsor_info, link_to_profile, + output_filename, ) = env.get_env_vars() self.assertEqual(organization, "org") @@ -79,6 +81,7 @@ 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, @@ -175,6 +178,7 @@ def test_get_env_vars_no_dates(self): end_date, sponsor_info, link_to_profile, + output_filename, ) = env.get_env_vars() self.assertEqual(organization, "org") @@ -189,12 +193,107 @@ 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, {}) + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "REPOSITORY": "repo,repo2", + "GH_APP_ID": "", + "GH_APP_INSTALLATION_ID": "", + "GH_APP_PRIVATE_KEY": "", + "GH_TOKEN": "token", + "GH_ENTERPRISE_URL": "", + "START_DATE": "", + "END_DATE": "", + "SPONSOR_INFO": "False", + "LINK_TO_PROFILE": "True", + "OUTPUT_FILENAME": "custom-report.md", + }, + clear=True, + ) + def test_get_env_vars_custom_output_filename(self): + """Test that OUTPUT_FILENAME overrides the default output filename.""" + ( + _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, + ) = env.get_env_vars() + + self.assertEqual(output_filename, "custom-report.md") + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "../../../etc/passwd", + }, + clear=True, + ) + def test_get_env_vars_output_filename_path_traversal_rejected(self): + """Test that OUTPUT_FILENAME rejects path traversal attempts.""" + with self.assertRaises(ValueError): + env.get_env_vars() + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "/tmp/output.md", + }, + clear=True, + ) + def test_get_env_vars_output_filename_absolute_path_rejected(self): + """Test that OUTPUT_FILENAME rejects absolute paths.""" + with self.assertRaises(ValueError): + env.get_env_vars() + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "reports/output.md", + }, + clear=True, + ) + def test_get_env_vars_output_filename_directory_separator_rejected(self): + """Test that OUTPUT_FILENAME rejects filenames with directory separators.""" + with self.assertRaises(ValueError): + env.get_env_vars() + + @patch.dict( + os.environ, + { + "ORGANIZATION": "org", + "GH_TOKEN": "token", + "OUTPUT_FILENAME": "file;rm -rf /.md", + }, + clear=True, + ) + def test_get_env_vars_output_filename_special_chars_rejected(self): + """Test that OUTPUT_FILENAME rejects filenames with special 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""" with self.assertRaises(ValueError) as cm: - env.get_env_vars() + env.get_env_vars(test=True) the_exception = cm.exception self.assertEqual( str(the_exception), @@ -290,6 +389,7 @@ def test_get_env_vars_valid_date_range(self): end_date, _sponsor_info, _link_to_profile, + _output_filename, ) = env.get_env_vars() self.assertEqual(start_date, "2024-01-01") self.assertEqual(end_date, "2025-01-01")