Skip to content

Commit 541f0ea

Browse files
authored
Merge pull request #409 from github-community-projects/fix/contributors-missing-rate-limit
fix: use commits-first approach to avoid rate limiting and missing contributors
2 parents 66f0aac + 5e52461 commit 541f0ea

File tree

4 files changed

+126
-84
lines changed

4 files changed

+126
-84
lines changed

contributors.py

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -155,41 +155,53 @@ def get_contributors(repo: object, start_date: str, end_date: str, ghe: str):
155155
Returns:
156156
contributors (list): A list of ContributorStats objects
157157
"""
158-
all_repo_contributors = repo.contributors()
159158
contributors = []
159+
endpoint = ghe if ghe else "https://github.com"
160160
try:
161-
for user in all_repo_contributors:
162-
# Ignore contributors with [bot] in their name
163-
if "[bot]" in user.login:
164-
continue
165-
166-
# Check if user has commits in the date range
167-
if start_date and end_date:
168-
user_commits = repo.commits(
169-
author=user.login, since=start_date, until=end_date
161+
if start_date and end_date:
162+
# Fetch commits in the date range and extract unique authors.
163+
# This is much more efficient than iterating all-time contributors
164+
# and checking each one for commits, which causes rate limiting
165+
# on large repositories.
166+
contributor_data = {}
167+
for commit in repo.commits(since=start_date, until=end_date):
168+
if commit.author is None:
169+
continue
170+
login = commit.author.login
171+
if "[bot]" in login:
172+
continue
173+
if login not in contributor_data:
174+
contributor_data[login] = {
175+
"avatar_url": commit.author.avatar_url,
176+
"contribution_count": 0,
177+
}
178+
contributor_data[login]["contribution_count"] += 1
179+
180+
for username, data in contributor_data.items():
181+
commit_url = f"{endpoint}/{repo.full_name}/commits?author={username}&since={start_date}&until={end_date}"
182+
contributor = contributor_stats.ContributorStats(
183+
username,
184+
False,
185+
data["avatar_url"],
186+
data["contribution_count"],
187+
commit_url,
188+
"",
170189
)
171-
172-
# If the user has no commits in the date range, skip them
173-
try:
174-
next(user_commits)
175-
except StopIteration:
190+
contributors.append(contributor)
191+
else:
192+
for user in repo.contributors():
193+
if "[bot]" in user.login:
176194
continue
177-
178-
# Store the contributor information in a ContributorStats object
179-
endpoint = ghe if ghe else "https://github.com"
180-
if start_date and end_date:
181-
commit_url = f"{endpoint}/{repo.full_name}/commits?author={user.login}&since={start_date}&until={end_date}"
182-
else:
183195
commit_url = f"{endpoint}/{repo.full_name}/commits?author={user.login}"
184-
contributor = contributor_stats.ContributorStats(
185-
user.login,
186-
False,
187-
user.avatar_url,
188-
user.contributions_count,
189-
commit_url,
190-
"",
191-
)
192-
contributors.append(contributor)
196+
contributor = contributor_stats.ContributorStats(
197+
user.login,
198+
False,
199+
user.avatar_url,
200+
user.contributions_count,
201+
commit_url,
202+
"",
203+
)
204+
contributors.append(contributor)
193205
except Exception as e:
194206
print(f"Error getting contributors for repository: {repo.full_name}")
195207
print(e)

markdown.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,10 @@ def get_contributor_table(
230230
sponsor_info = _is_truthy(sponsor_info)
231231
show_avatar = _is_truthy(show_avatar)
232232
link_to_profile = _is_truthy(link_to_profile)
233-
columns = ["Username", "All Time Contribution Count"]
233+
if start_date and end_date:
234+
columns = ["Username", "Contribution Count"]
235+
else:
236+
columns = ["Username", "All Time Contribution Count"]
234237
if show_avatar:
235238
columns.insert(0, "Avatar")
236239
if start_date and end_date:

test_contributors.py

Lines changed: 73 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import runpy
44
import unittest
5-
from unittest.mock import MagicMock, call, patch
5+
from unittest.mock import MagicMock, patch
66

77
import contributors as contributors_module
88
from contributor_stats import ContributorStats
@@ -19,24 +19,27 @@ def test_get_contributors(self, mock_contributor_stats):
1919
Test the get_contributors function.
2020
"""
2121
mock_repo = MagicMock()
22-
mock_user = MagicMock()
23-
mock_user.login = "user"
24-
mock_user.avatar_url = "https://avatars.githubusercontent.com/u/12345678?v=4"
25-
mock_user.contributions_count = 100
26-
mock_repo.contributors.return_value = [mock_user]
22+
mock_commit = MagicMock()
23+
mock_commit.author.login = "user"
24+
mock_commit.author.avatar_url = (
25+
"https://avatars.githubusercontent.com/u/12345678?v=4"
26+
)
2727
mock_repo.full_name = "owner/repo"
28-
mock_repo.commits.return_value = iter([object()])
28+
mock_repo.commits.return_value = iter([mock_commit])
2929

30-
contributors_module.get_contributors(mock_repo, "2022-01-01", "2022-12-31", "")
30+
result = contributors_module.get_contributors(
31+
mock_repo, "2022-01-01", "2022-12-31", ""
32+
)
3133

34+
self.assertEqual(len(result), 1)
3235
mock_repo.commits.assert_called_once_with(
33-
author="user", since="2022-01-01", until="2022-12-31"
36+
since="2022-01-01", until="2022-12-31"
3437
)
3538
mock_contributor_stats.assert_called_once_with(
3639
"user",
3740
False,
3841
"https://avatars.githubusercontent.com/u/12345678?v=4",
39-
100,
42+
1,
4043
"https://github.com/owner/repo/commits?author=user&since=2022-01-01&until=2022-12-31",
4144
"",
4245
)
@@ -124,41 +127,34 @@ def test_get_all_contributors_with_repository(self, mock_get_contributors):
124127
)
125128

126129
@patch("contributors.contributor_stats.ContributorStats")
127-
def test_get_contributors_skip_users_with_no_commits(self, mock_contributor_stats):
130+
def test_get_contributors_with_single_commit(self, mock_contributor_stats):
128131
"""
129-
Test the get_contributors function skips users with no commits in the date range.
132+
Test get_contributors returns a single contributor for one commit in the date range.
130133
"""
131134
mock_repo = MagicMock()
132-
mock_user = MagicMock()
133-
mock_user.login = "user"
134-
mock_user.avatar_url = "https://avatars.githubusercontent.com/u/12345678?v=4"
135-
mock_user.contributions_count = 100
136-
mock_user2 = MagicMock()
137-
mock_user2.login = "user2"
138-
mock_user2.avatar_url = "https://avatars.githubusercontent.com/u/12345679?v=4"
139-
mock_user2.contributions_count = 102
135+
mock_commit = MagicMock()
136+
mock_commit.author.login = "user"
137+
mock_commit.author.avatar_url = (
138+
"https://avatars.githubusercontent.com/u/12345678?v=4"
139+
)
140140

141-
mock_repo.contributors.return_value = [mock_user, mock_user2]
142141
mock_repo.full_name = "owner/repo"
143-
mock_repo.commits.side_effect = [
144-
iter([object()]), # user has commits in range
145-
iter([]), # user2 has no commits in range and should be skipped
146-
]
142+
mock_repo.commits.return_value = iter([mock_commit])
147143
ghe = ""
148144

149-
contributors_module.get_contributors(mock_repo, "2022-01-01", "2022-12-31", ghe)
145+
result = contributors_module.get_contributors(
146+
mock_repo, "2022-01-01", "2022-12-31", ghe
147+
)
150148

151-
mock_repo.commits.assert_has_calls(
152-
[
153-
call(author="user", since="2022-01-01", until="2022-12-31"),
154-
call(author="user2", since="2022-01-01", until="2022-12-31"),
155-
]
149+
self.assertEqual(len(result), 1)
150+
mock_repo.commits.assert_called_once_with(
151+
since="2022-01-01", until="2022-12-31"
156152
)
157153
mock_contributor_stats.assert_called_once_with(
158154
"user",
159155
False,
160156
"https://avatars.githubusercontent.com/u/12345678?v=4",
161-
100,
157+
1,
162158
"https://github.com/owner/repo/commits?author=user&since=2022-01-01&until=2022-12-31",
163159
"",
164160
)
@@ -169,19 +165,22 @@ def test_get_contributors_skip_bot(self, mock_contributor_stats):
169165
Test if the get_contributors function skips the bot user.
170166
"""
171167
mock_repo = MagicMock()
172-
mock_user = MagicMock()
173-
mock_user.login = "[bot]"
174-
mock_user.avatar_url = "https://avatars.githubusercontent.com/u/12345678?v=4"
175-
mock_user.contributions_count = 100
168+
mock_commit = MagicMock()
169+
mock_commit.author.login = "[bot]"
170+
mock_commit.author.avatar_url = (
171+
"https://avatars.githubusercontent.com/u/12345678?v=4"
172+
)
176173

177-
mock_repo.contributors.return_value = [mock_user]
178174
mock_repo.full_name = "owner/repo"
175+
mock_repo.commits.return_value = iter([mock_commit])
179176
ghe = ""
180177

181-
contributors_module.get_contributors(mock_repo, "2022-01-01", "2022-12-31", ghe)
178+
result = contributors_module.get_contributors(
179+
mock_repo, "2022-01-01", "2022-12-31", ghe
180+
)
182181

182+
self.assertEqual(result, [])
183183
# Ensure that the bot user is skipped and ContributorStats is never instantiated
184-
mock_repo.commits.assert_not_called()
185184
mock_contributor_stats.assert_not_called()
186185

187186
@patch("contributors.contributor_stats.ContributorStats")
@@ -212,13 +211,8 @@ def test_get_contributors_no_commit_end_date(self, mock_contributor_stats):
212211
)
213212

214213
def test_get_contributors_skips_when_no_commits_in_range(self):
215-
"""Test get_contributors skips users with no commits in the date range."""
214+
"""Test get_contributors returns empty list when no commits in the date range."""
216215
mock_repo = MagicMock()
217-
mock_user = MagicMock()
218-
mock_user.login = "user"
219-
mock_user.avatar_url = "https://avatars.githubusercontent.com/u/12345678?v=4"
220-
mock_user.contributions_count = 100
221-
mock_repo.contributors.return_value = [mock_user]
222216
mock_repo.full_name = "owner/repo"
223217
mock_repo.commits.return_value = iter([])
224218

@@ -228,6 +222,39 @@ def test_get_contributors_skips_when_no_commits_in_range(self):
228222

229223
self.assertEqual(result, [])
230224

225+
def test_get_contributors_skips_none_author(self):
226+
"""Test get_contributors skips commits with no linked GitHub author."""
227+
mock_repo = MagicMock()
228+
mock_repo.full_name = "owner/repo"
229+
mock_commit = MagicMock()
230+
mock_commit.author = None
231+
mock_repo.commits.return_value = iter([mock_commit])
232+
233+
result = contributors_module.get_contributors(
234+
mock_repo, "2022-01-01", "2022-12-31", ""
235+
)
236+
237+
self.assertEqual(result, [])
238+
239+
def test_get_contributors_aggregates_multiple_commits(self):
240+
"""Test get_contributors counts multiple commits per author correctly."""
241+
mock_repo = MagicMock()
242+
mock_repo.full_name = "owner/repo"
243+
mock_commit1 = MagicMock()
244+
mock_commit1.author.login = "user"
245+
mock_commit1.author.avatar_url = "https://avatars.githubusercontent.com/u/1"
246+
mock_commit2 = MagicMock()
247+
mock_commit2.author.login = "user"
248+
mock_commit2.author.avatar_url = "https://avatars.githubusercontent.com/u/1"
249+
mock_repo.commits.return_value = iter([mock_commit1, mock_commit2])
250+
251+
result = contributors_module.get_contributors(
252+
mock_repo, "2022-01-01", "2022-12-31", ""
253+
)
254+
255+
self.assertEqual(len(result), 1)
256+
self.assertEqual(result[0].contribution_count, 2)
257+
231258
def test_get_contributors_handles_exception(self):
232259
"""Test get_contributors returns None when an exception is raised."""
233260

@@ -239,7 +266,7 @@ def __iter__(self):
239266

240267
mock_repo = MagicMock()
241268
mock_repo.full_name = "owner/repo"
242-
mock_repo.contributors.return_value = BoomIterable()
269+
mock_repo.commits.return_value = BoomIterable()
243270

244271
with patch("builtins.print") as mock_print:
245272
result = contributors_module.get_contributors(

test_markdown.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def test_write_to_markdown(
6767
"| Total Contributors | Total Contributions | % New Contributors |\n"
6868
"| --- | --- | --- |\n"
6969
"| 2 | 300 | 50.0% |\n\n"
70-
"| Username | All Time Contribution Count | New Contributor | "
70+
"| Username | Contribution Count | New Contributor | "
7171
"Commits between 2023-01-01 and 2023-01-02 |\n"
7272
"| --- | --- | --- | --- |\n"
7373
"| @user1 | 100 | False | commit url |\n"
@@ -133,7 +133,7 @@ def test_write_to_markdown_with_sponsors(
133133
"| Total Contributors | Total Contributions | % New Contributors |\n"
134134
"| --- | --- | --- |\n"
135135
"| 2 | 300 | 50.0% |\n\n"
136-
"| Username | All Time Contribution Count | New Contributor | "
136+
"| Username | Contribution Count | New Contributor | "
137137
"Sponsor URL | Commits between 2023-01-01 and 2023-01-02 |\n"
138138
"| --- | --- | --- | --- | --- |\n"
139139
"| @user1 | 100 | False | [Sponsor Link](sponsor_url_1) | commit url |\n"
@@ -200,7 +200,7 @@ def test_write_to_markdown_with_avatars(
200200
"| Total Contributors | Total Contributions | % New Contributors |\n"
201201
"| --- | --- | --- |\n"
202202
"| 2 | 300 | 50.0% |\n\n"
203-
"| Avatar | Username | All Time Contribution Count | New Contributor | "
203+
"| Avatar | Username | Contribution Count | New Contributor | "
204204
"Commits between 2023-01-01 and 2023-01-02 |\n"
205205
"| --- | --- | --- | --- | --- |\n"
206206
'| <img src="https://avatars.example.com/user1.png" width="32" height="32" /> | '
@@ -268,7 +268,7 @@ def test_write_to_markdown_without_link_to_profile(
268268
"| Total Contributors | Total Contributions | % New Contributors |\n"
269269
"| --- | --- | --- |\n"
270270
"| 2 | 300 | 50.0% |\n\n"
271-
"| Username | All Time Contribution Count | New Contributor | "
271+
"| Username | Contribution Count | New Contributor | "
272272
"Commits between 2023-01-01 and 2023-01-02 |\n"
273273
"| --- | --- | --- | --- |\n"
274274
"| user1 | 100 | False | commit url |\n"
@@ -394,7 +394,7 @@ def test_write_to_markdown_with_organization(
394394
"| Total Contributors | Total Contributions | % New Contributors |\n"
395395
"| --- | --- | --- |\n"
396396
"| 2 | 300 | 50.0% |\n\n"
397-
"| Username | All Time Contribution Count | New Contributor | "
397+
"| Username | Contribution Count | New Contributor | "
398398
"Commits between 2023-01-01 and 2023-01-02 |\n"
399399
"| --- | --- | --- | --- |\n"
400400
"| @user1 | 100 | False | "
@@ -442,7 +442,7 @@ def test_write_to_markdown_empty_collaborators(
442442
"| Total Contributors | Total Contributions | % New Contributors |\n"
443443
"| --- | --- | --- |\n"
444444
"| 0 | 0 | 0% |\n\n"
445-
"| Username | All Time Contribution Count | New Contributor | "
445+
"| Username | Contribution Count | New Contributor | "
446446
"Commits between 2023-01-01 and 2023-01-02 |\n"
447447
"| --- | --- | --- | --- |\n"
448448
"\n _this file was generated by the "
@@ -555,7 +555,7 @@ def test_write_to_markdown_with_ghe(
555555
"| Total Contributors | Total Contributions | % New Contributors |\n"
556556
"| --- | --- | --- |\n"
557557
"| 1 | 100 | 0.0% |\n\n"
558-
"| Username | All Time Contribution Count | New Contributor | "
558+
"| Username | Contribution Count | New Contributor | "
559559
"Commits between 2023-01-01 and 2023-01-02 |\n"
560560
"| --- | --- | --- | --- |\n"
561561
"| @user1 | 100 | False | "

0 commit comments

Comments
 (0)