Skip to content
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -119,6 +120,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
issues: write
env:
OUTPUT_FILENAME: contributors.md

steps:
- name: Get dates for last month
Expand Down Expand Up @@ -148,7 +151,7 @@ jobs:
with:
title: Monthly contributor report
token: ${{ secrets.GITHUB_TOKEN }}
content-filepath: ./contributors.md
content-filepath: ./${{ env.OUTPUT_FILENAME }}
assignees: <YOUR_GITHUB_HANDLE_HERE>
```

Expand All @@ -170,6 +173,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
issues: write
env:
OUTPUT_FILENAME: contributors.md

steps:
- name: Get dates for last month
Expand Down Expand Up @@ -204,7 +209,7 @@ jobs:
with:
title: Monthly contributor report
token: ${{ secrets.GITHUB_TOKEN }}
content-filepath: ./contributors.md
content-filepath: ./${{ env.OUTPUT_FILENAME }}
assignees: <YOUR_GITHUB_HANDLE_HERE>
```

Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion contributors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def main():
end_date,
sponsor_info,
link_to_profile,
output_filename,
) = env.get_env_vars()

# Auth to GitHub.com
Expand Down Expand Up @@ -75,7 +76,7 @@ def main():
# print(contributors)
markdown.write_to_markdown(
contributors,
"contributors.md",
output_filename,
start_date,
end_date,
organization,
Expand Down
16 changes: 15 additions & 1 deletion env.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import datetime
import os
import re
from os.path import dirname, join

from dotenv import load_dotenv
Expand Down Expand Up @@ -115,6 +116,7 @@ def get_env_vars(
str,
bool,
bool,
str,
]:
"""
Get the environment variables for use in the action.
Expand All @@ -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:
Expand Down Expand Up @@ -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 = []
Expand All @@ -197,4 +210,5 @@ def get_env_vars(
end_date,
sponsor_info,
link_to_profile,
output_filename,
)
3 changes: 3 additions & 0 deletions test_contributors.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ def test_main_runs_under_main_guard(self):
"2022-12-31",
False,
False,
"contributors.md",
)

mock_auth = MagicMock()
Expand Down Expand Up @@ -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], []]
Expand Down Expand Up @@ -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]
Expand Down
104 changes: 102 additions & 2 deletions test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def setUp(self):
"GITHUB_APP_ENTERPRISE_ONLY",
"GH_TOKEN",
"ORGANIZATION",
"OUTPUT_FILENAME",
"REPOSITORY",
"START_DATE",
]
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -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),
Expand Down Expand Up @@ -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")
Expand Down
Loading