Skip to content

Commit 7953acb

Browse files
committed
update code
1 parent 59b165e commit 7953acb

File tree

17 files changed

+658
-54
lines changed

17 files changed

+658
-54
lines changed

backend/Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ update-data: \
156156
github-add-related-repositories \
157157
github-update-related-organizations \
158158
github-update-users \
159+
github-aggregate-contributions \
159160
owasp-aggregate-projects \
160161
owasp-aggregate-contributions \
161162
owasp-update-events \

backend/apps/github/Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ github-update-related-organizations:
2121
github-update-users:
2222
@echo "Updating GitHub users"
2323
@CMD="python manage.py github_update_users" $(MAKE) exec-backend-command
24+
25+
github-aggregate-contributions:
26+
@echo "Aggregating GitHub user contributions"
27+
@CMD="python manage.py github_aggregate_contributions" $(MAKE) exec-backend-command

backend/apps/github/api/internal/nodes/user.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"avatar_url",
1414
"bio",
1515
"company",
16+
"contribution_data",
1617
"contributions_count",
1718
"email",
1819
"followers_count",
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Management command to aggregate user contribution data."""
2+
3+
from datetime import datetime, timedelta
4+
from typing import Any
5+
6+
from django.core.management.base import BaseCommand
7+
from django.db.models import Count
8+
from django.utils import timezone
9+
10+
from apps.github.models.commit import Commit
11+
from apps.github.models.issue import Issue
12+
from apps.github.models.pull_request import PullRequest
13+
from apps.github.models.user import User
14+
15+
16+
class Command(BaseCommand):
17+
"""Aggregate contribution data for users."""
18+
19+
help = "Aggregate contribution data (commits, PRs, issues) for users"
20+
21+
def add_arguments(self, parser):
22+
"""Add command arguments."""
23+
parser.add_argument(
24+
"--user",
25+
type=str,
26+
help="Specific user login to process",
27+
)
28+
parser.add_argument(
29+
"--days",
30+
type=int,
31+
default=365,
32+
help="Number of days to look back (default: 365)",
33+
)
34+
parser.add_argument(
35+
"--batch-size",
36+
type=int,
37+
default=100,
38+
help="Batch size for processing users (default: 100)",
39+
)
40+
41+
def handle(self, *args: Any, **options: Any) -> None:
42+
"""Handle the command execution."""
43+
user_login = options.get("user")
44+
days = options.get("days", 365)
45+
batch_size = options.get("batch_size", 1000)
46+
47+
start_date = timezone.now() - timedelta(days=days)
48+
49+
self.stdout.write(
50+
self.style.SUCCESS(
51+
f"Aggregating contributions since {start_date.date()} ({days} days back)"
52+
)
53+
)
54+
55+
if user_login:
56+
users = User.objects.filter(login=user_login)
57+
if not users.exists():
58+
self.stdout.write(self.style.ERROR(f"User '{user_login}' not found"))
59+
return
60+
else:
61+
users = User.objects.filter(contributions_count__gt=0)
62+
63+
total_users = users.count()
64+
self.stdout.write(f"Processing {total_users} users...")
65+
66+
processed = 0
67+
for user in users.iterator(chunk_size=batch_size):
68+
contribution_data = self._aggregate_user_contributions(user, start_date)
69+
70+
if contribution_data:
71+
user.contribution_data = contribution_data
72+
user.save(update_fields=["contribution_data"])
73+
processed += 1
74+
75+
if processed % 100 == 0:
76+
self.stdout.write(f"Processed {processed}/{total_users} users...")
77+
78+
self.stdout.write(
79+
self.style.SUCCESS(f"Successfully aggregated contributions for {processed} users")
80+
)
81+
82+
def _aggregate_user_contributions(self, user: User, start_date: datetime) -> dict[str, int]:
83+
"""Aggregate contributions for a user.
84+
85+
Args:
86+
user: User instance
87+
start_date: Start datetime for aggregation
88+
89+
Returns:
90+
Dictionary mapping YYYY-MM-DD to contribution counts
91+
92+
"""
93+
contribution_data = {}
94+
current_date = start_date.date()
95+
end_date = timezone.now().date()
96+
97+
while current_date <= end_date:
98+
date_str = current_date.strftime("%Y-%m-%d")
99+
contribution_data[date_str] = 0
100+
current_date += timedelta(days=1)
101+
102+
commits = (
103+
Commit.objects.filter(
104+
author=user,
105+
created_at__gte=start_date,
106+
)
107+
.values("created_at")
108+
.annotate(count=Count("id"))
109+
)
110+
111+
for commit in commits:
112+
date_str = commit["created_at"].strftime("%Y-%m-%d")
113+
contribution_data[date_str] = contribution_data.get(date_str, 0) + commit["count"]
114+
115+
prs = (
116+
PullRequest.objects.filter(
117+
author=user,
118+
created_at__gte=start_date,
119+
)
120+
.values("created_at")
121+
.annotate(count=Count("id"))
122+
)
123+
124+
for pr in prs:
125+
date_str = pr["created_at"].strftime("%Y-%m-%d")
126+
contribution_data[date_str] = contribution_data.get(date_str, 0) + pr["count"]
127+
128+
issues = (
129+
Issue.objects.filter(
130+
author=user,
131+
created_at__gte=start_date,
132+
)
133+
.values("created_at")
134+
.annotate(count=Count("id"))
135+
)
136+
137+
for issue in issues:
138+
date_str = issue["created_at"].strftime("%Y-%m-%d")
139+
contribution_data[date_str] = contribution_data.get(date_str, 0) + issue["count"]
140+
141+
return contribution_data
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 6.0.1 on 2026-01-12 19:20
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("github", "0040_merge_20251117_0136"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="user",
14+
name="contribution_data",
15+
field=models.JSONField(
16+
blank=True,
17+
default=dict,
18+
help_text="Aggregated contribution data as date -> count mapping",
19+
verbose_name="Contribution heatmap data",
20+
),
21+
),
22+
]

backend/apps/github/models/user.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ class Meta:
5454
verbose_name="Contributions count", default=0
5555
)
5656

57+
contribution_data = models.JSONField(
58+
verbose_name="Contribution heatmap data",
59+
default=dict,
60+
blank=True,
61+
help_text="Aggregated contribution data as date -> count mapping",
62+
)
63+
5764
def __str__(self) -> str:
5865
"""Return a human-readable representation of the user.
5966

backend/tests/apps/github/api/internal/nodes/user_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def test_meta_configuration(self):
2323
"badges",
2424
"bio",
2525
"company",
26+
"contribution_data",
2627
"contributions_count",
2728
"created_at",
2829
"email",
@@ -308,3 +309,30 @@ def test_linkedin_page_id_without_profile(self):
308309
result = UserNode.linkedin_page_id(mock_user)
309310

310311
assert result == ""
312+
313+
def test_contribution_data_field(self):
314+
"""Test contribution_data field returns stored data."""
315+
mock_user = Mock()
316+
mock_user.contribution_data = {
317+
"2025-01-01": 5,
318+
"2025-01-02": 3,
319+
"2025-01-03": 0,
320+
}
321+
322+
# Access the field directly since it's just a passthrough
323+
result = mock_user.contribution_data
324+
325+
assert result == {
326+
"2025-01-01": 5,
327+
"2025-01-02": 3,
328+
"2025-01-03": 0,
329+
}
330+
331+
def test_contribution_data_field_empty(self):
332+
"""Test contribution_data field when empty."""
333+
mock_user = Mock()
334+
mock_user.contribution_data = {}
335+
336+
result = mock_user.contribution_data
337+
338+
assert result == {}

0 commit comments

Comments
 (0)