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,