diff --git a/backend/Makefile b/backend/Makefile index e946e35b6c..1411db9f60 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -210,7 +210,8 @@ update-data: \ github-update-related-organizations \ github-update-users \ owasp-aggregate-projects \ - owasp-aggregate-contributions \ + owasp-aggregate-entity-contributions \ + owasp-aggregate-member-contributions \ owasp-update-events \ owasp-sync-posts \ owasp-update-sponsors \ diff --git a/backend/apps/github/api/internal/nodes/user.py b/backend/apps/github/api/internal/nodes/user.py index e7f635cbfc..8451dd7d60 100644 --- a/backend/apps/github/api/internal/nodes/user.py +++ b/backend/apps/github/api/internal/nodes/user.py @@ -12,6 +12,7 @@ "avatar_url", "bio", "company", + "contribution_data", "contributions_count", "email", "followers_count", diff --git a/backend/apps/github/migrations/0041_user_contribution_data.py b/backend/apps/github/migrations/0041_user_contribution_data.py new file mode 100644 index 0000000000..7ebd1e59b9 --- /dev/null +++ b/backend/apps/github/migrations/0041_user_contribution_data.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.1 on 2026-01-12 19:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0040_merge_20251117_0136"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Aggregated contribution data as date -> count mapping", + verbose_name="Contribution heatmap data", + ), + ), + ] diff --git a/backend/apps/github/migrations/0042_merge_20260127_2218.py b/backend/apps/github/migrations/0042_merge_20260127_2218.py new file mode 100644 index 0000000000..7585564fd7 --- /dev/null +++ b/backend/apps/github/migrations/0042_merge_20260127_2218.py @@ -0,0 +1,12 @@ +# Generated by Django 6.0.1 on 2026-01-27 22:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0041_milestone_github_milestone_created_at_and_more"), + ("github", "0041_user_contribution_data"), + ] + + operations = [] diff --git a/backend/apps/github/migrations/0043_alter_user_contribution_data.py b/backend/apps/github/migrations/0043_alter_user_contribution_data.py new file mode 100644 index 0000000000..2eed539ff3 --- /dev/null +++ b/backend/apps/github/migrations/0043_alter_user_contribution_data.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-01-28 01:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0042_merge_20260127_2218"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Aggregated contribution data as date -> count mapping", + null=True, + verbose_name="Contribution heatmap data", + ), + ), + ] diff --git a/backend/apps/github/models/user.py b/backend/apps/github/models/user.py index 05a3980db5..72d07acce2 100644 --- a/backend/apps/github/models/user.py +++ b/backend/apps/github/models/user.py @@ -54,6 +54,14 @@ class Meta: verbose_name="Contributions count", default=0 ) + contribution_data = models.JSONField( + verbose_name="Contribution heatmap data", + default=dict, + blank=True, + null=True, + help_text="Aggregated contribution data as date -> count mapping", + ) + def __str__(self) -> str: """Return a human-readable representation of the user. diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 75bdba3b70..c74b6e8362 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -2,10 +2,14 @@ owasp-aggregate-projects: @echo "Aggregating OWASP projects" @CMD="python manage.py owasp_aggregate_projects" $(MAKE) exec-backend-command -owasp-aggregate-contributions: +owasp-aggregate-entity-contributions: @echo "Aggregating OWASP contributions" - @CMD="python manage.py owasp_aggregate_contributions --entity-type chapter" $(MAKE) exec-backend-command - @CMD="python manage.py owasp_aggregate_contributions --entity-type project" $(MAKE) exec-backend-command + @CMD="python manage.py owasp_aggregate_entity_contributions --entity-type chapter" $(MAKE) exec-backend-command + @CMD="python manage.py owasp_aggregate_entity_contributions --entity-type project" $(MAKE) exec-backend-command + +owasp-aggregate-member-contributions: + @echo "Aggregating OWASP community member contributions" + @CMD="python manage.py owasp_aggregate_member_contributions" $(MAKE) exec-backend-command owasp-create-project-metadata-file: @echo "Generating metadata" diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py b/backend/apps/owasp/management/commands/owasp_aggregate_entity_contributions.py similarity index 100% rename from backend/apps/owasp/management/commands/owasp_aggregate_contributions.py rename to backend/apps/owasp/management/commands/owasp_aggregate_entity_contributions.py diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py b/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py new file mode 100644 index 0000000000..2d77aa0e97 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py @@ -0,0 +1,140 @@ +"""Management command to aggregate user contribution data.""" + +from datetime import datetime, timedelta +from typing import Any + +from django.core.management.base import BaseCommand +from django.db.models import Count +from django.db.models.functions import TruncDate +from django.utils import timezone + +from apps.github.models.commit import Commit +from apps.github.models.issue import Issue +from apps.github.models.pull_request import PullRequest +from apps.github.models.user import User + + +class Command(BaseCommand): + """Aggregate contribution data for users.""" + + help = "Aggregate contribution data (commits, PRs, issues) for users" + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--user", + type=str, + help="Specific user login to process", + ) + parser.add_argument( + "--days", + type=int, + default=365, + help="Number of days to look back (default: 365)", + ) + parser.add_argument( + "--batch-size", + type=int, + default=100, + help="Batch size for processing users (default: 100)", + ) + + def handle(self, *args: Any, **options: Any) -> None: + """Handle the command execution.""" + user_login = options.get("user") + days = options.get("days", 365) + batch_size = options.get("batch_size", 100) + + start_date = timezone.now() - timedelta(days=days) + + self.stdout.write( + self.style.SUCCESS( + f"Aggregating contributions since {start_date.date()} ({days} days back)" + ) + ) + + if user_login: + users = User.objects.filter(login=user_login) + if not users.exists(): + self.stdout.write(self.style.ERROR(f"Member '{user_login}' not found")) + return + else: + users = User.objects.filter(contributions_count__gt=0) + + total_users = users.count() + self.stdout.write(f"Processing {total_users} members...") + + updated_users = [] + for user in users.iterator(chunk_size=batch_size): + user.contribution_data = self._aggregate_user_contributions(user, start_date) + updated_users.append(user) + + User.bulk_save(updated_users, fields=["contribution_data"]) + + self.stdout.write( + self.style.SUCCESS(f"Successfully aggregated contributions for {total_users} members") + ) + + def _aggregate_user_contributions(self, user: User, start_date: datetime) -> dict[str, int]: + """Aggregate contributions for a user. + + Args: + user: User instance + start_date: Start datetime for aggregation + + Returns: + Dictionary mapping YYYY-MM-DD to contribution counts + + """ + contribution_data = {} + current_date = start_date.date() + end_date = timezone.now().date() + + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + contribution_data[date_str] = 0 + current_date += timedelta(days=1) + + commits = ( + Commit.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for commit in commits: + date_str = commit["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + commit["count"] + + prs = ( + PullRequest.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for pr in prs: + date_str = pr["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + pr["count"] + + issues = ( + Issue.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for issue in issues: + date_str = issue["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + issue["count"] + + return contribution_data diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py.bak b/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py.bak new file mode 100644 index 0000000000..48686fe8f8 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py.bak @@ -0,0 +1,142 @@ +"""Management command to aggregate user contribution data.""" + +from datetime import datetime, timedelta +from typing import Any + +from django.core.management.base import BaseCommand +from django.db.models import Count +from django.db.models.functions import TruncDate +from django.utils import timezone + +from apps.github.models.commit import Commit +from apps.github.models.issue import Issue +from apps.github.models.pull_request import PullRequest +from apps.github.models.user import User + + +class Command(BaseCommand): + """Aggregate contribution data for users.""" + + help = "Aggregate contribution data (commits, PRs, issues) for users" + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--user", + type=str, + help="Specific user login to process", + ) + parser.add_argument( + "--days", + type=int, + default=365, + help="Number of days to look back (default: 365)", + ) + parser.add_argument( + "--batch-size", + type=int, + default=100, + help="Batch size for processing users (default: 100)", + ) + + def handle(self, *args: Any, **options: Any) -> None: + """Handle the command execution.""" + user_login = options.get("user") + days = options.get("days", 365) + batch_size = options.get("batch_size", 100) + + start_date = timezone.now() - timedelta(days=days) + + self.stdout.write( + self.style.SUCCESS( + f"Aggregating contributions since {start_date.date()} ({days} days back)" + ) + ) + + if user_login: + users = User.objects.filter(login=user_login) + if not users.exists(): + self.stdout.write(self.style.ERROR(f"Member '{user_login}' not found")) + return + else: + users = User.objects.filter(contributions_count__gt=0) + + total_users = users.count() + self.stdout.write(f"Processing {total_users} members...") + + updated_users = [] + for user in users.iterator(chunk_size=batch_size): + if not (contribution_data := self._aggregate_user_contributions(user, start_date)): + continue + user.contribution_data = contribution_data + updated_users.append(user) + + User.bulk_save(updated_users, fields=["contribution_data"]) + + self.stdout.write( + self.style.SUCCESS(f"Successfully aggregated contributions for {total_users} members") + ) + + def _aggregate_user_contributions(self, user: User, start_date: datetime) -> dict[str, int]: + """Aggregate contributions for a user. + + Args: + user: User instance + start_date: Start datetime for aggregation + + Returns: + Dictionary mapping YYYY-MM-DD to contribution counts + + """ + contribution_data = {} + current_date = start_date.date() + end_date = timezone.now().date() + + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + contribution_data[date_str] = 0 + current_date += timedelta(days=1) + + commits = ( + Commit.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for commit in commits: + date_str = commit["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + commit["count"] + + prs = ( + PullRequest.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for pr in prs: + date_str = pr["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + pr["count"] + + issues = ( + Issue.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for issue in issues: + date_str = issue["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + issue["count"] + + return contribution_data diff --git a/backend/tests/apps/github/api/internal/nodes/user_test.py b/backend/tests/apps/github/api/internal/nodes/user_test.py index b9356ec5a1..d88120382d 100644 --- a/backend/tests/apps/github/api/internal/nodes/user_test.py +++ b/backend/tests/apps/github/api/internal/nodes/user_test.py @@ -25,6 +25,7 @@ def test_meta_configuration(self): "badges", "bio", "company", + "contribution_data", "contributions_count", "created_at", "email", diff --git a/backend/tests/apps/github/models/user_test.py b/backend/tests/apps/github/models/user_test.py index 609d5174ac..1e7022112f 100644 --- a/backend/tests/apps/github/models/user_test.py +++ b/backend/tests/apps/github/models/user_test.py @@ -215,3 +215,34 @@ def test_get_non_indexable_logins(self, mock_get_logins): "org2", } assert User.get_non_indexable_logins() == expected_logins + + def test_contribution_data_default(self): + """Test that contribution_data defaults to empty dict.""" + user = User(login="testuser", node_id="U_test123") + assert user.contribution_data == {} + + def test_contribution_data_storage(self): + """Test that contribution_data can store and retrieve JSON data.""" + user = User( + login="testuser", + node_id="U_test123", + contribution_data={ + "2025-01-01": 5, + "2025-01-02": 3, + "2025-01-03": 0, + }, + ) + assert user.contribution_data["2025-01-01"] == 5 + assert user.contribution_data["2025-01-02"] == 3 + assert user.contribution_data["2025-01-03"] == 0 + + def test_contribution_data_update(self): + """Test updating contribution_data field.""" + user = User(login="testuser", node_id="U_test123") + user.contribution_data = {"2025-01-01": 10} + assert user.contribution_data == {"2025-01-01": 10} + + # Update with new data + user.contribution_data = {"2025-01-01": 15, "2025-01-02": 5} + assert user.contribution_data["2025-01-01"] == 15 + assert user.contribution_data["2025-01-02"] == 5 diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py similarity index 92% rename from backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py rename to backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py index e988e6d061..b4c4999bf8 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py @@ -1,11 +1,11 @@ -"""Test cases for owasp_aggregate_contributions management command.""" +"""Test cases for owasp_aggregate_entity_contributions management command.""" from datetime import UTC, datetime, timedelta from unittest import mock import pytest -from apps.owasp.management.commands.owasp_aggregate_contributions import Command +from apps.owasp.management.commands.owasp_aggregate_entity_contributions import Command from apps.owasp.models import Chapter, Project @@ -120,10 +120,10 @@ def test_aggregate_contribution_dates_helper(self, command): "2024-11-17": 1, } - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_aggregate_chapter_contributions( self, mock_release, @@ -157,10 +157,10 @@ def test_aggregate_chapter_contributions( "2024-11-17": 2, } - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_aggregate_project_contributions( self, mock_release, @@ -215,11 +215,11 @@ def test_aggregate_project_without_repositories(self, command, mock_project): assert result == {} - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_chapters_only( self, mock_release, @@ -250,11 +250,11 @@ def test_handle_chapters_only( assert mock_chapter.contribution_data == {"2024-11-16": 5} assert mock_chapter_model.bulk_save.called - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Project") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_projects_only( self, mock_release, @@ -287,12 +287,12 @@ def test_handle_projects_only( assert "commits" in mock_project.contribution_stats assert mock_project_model.bulk_save.called - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Project") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_both_entities( self, mock_release, @@ -328,11 +328,11 @@ def test_handle_both_entities( assert mock_chapter_model.bulk_save.called assert mock_project_model.bulk_save.called - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_with_specific_key( self, mock_release, @@ -363,11 +363,11 @@ def test_handle_with_specific_key( # Verify filter was called with the specific key. mock_chapter_model.objects.filter.assert_called() - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_with_offset( self, mock_release, @@ -403,11 +403,11 @@ def test_handle_with_offset( mock_aggregate.assert_called_once() mock_chapter_model.bulk_save.assert_called_once() - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest") - @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_custom_days( self, mock_release, diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py new file mode 100644 index 0000000000..e61d5dfefa --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py @@ -0,0 +1,287 @@ +"""Tests for community member contributions aggregation command.""" + +from datetime import UTC, datetime, timedelta +from unittest import mock + +from apps.owasp.management.commands.owasp_aggregate_member_contributions import Command + + +class MockQuerySet: + """Mock QuerySet that supports iteration and chaining without database access.""" + + def __init__(self, items): + self._items = items + + def __iter__(self): + """Return iterator over items.""" + return iter(self._items) + + def filter(self, **kwargs): + """Mock filter method.""" + return self + + def exists(self): + """Mock exists method.""" + return len(self._items) > 0 + + def count(self): + """Return count of items.""" + return len(self._items) + + def iterator(self, **kwargs): + """Mock iterator method.""" + return iter(self._items) + + def values(self, *args, **kwargs): + """Mock values method.""" + return self + + def annotate(self, **kwargs): + """Mock annotate method.""" + return self + + +class TestAggregateContributionsCommand: + """Test suite for community member contributions aggregation command.""" + + def test_aggregate_user_contributions_empty(self): + """Test aggregation with no contributions.""" + command = Command() + mock_user = mock.Mock() + mock_user.id = 1 + + # Use fixed dates for testing + fixed_now = datetime(2024, 1, 30, tzinfo=UTC) + start_date = datetime(2024, 1, 1, tzinfo=UTC) + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Commit" + ) as mock_commit, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.PullRequest" + ) as mock_pr, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Issue" + ) as mock_issue, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.timezone.now" + ) as mock_tz_now, + ): + mock_tz_now.return_value = fixed_now + + mock_commit.objects.filter.return_value = MockQuerySet([]) + mock_pr.objects.filter.return_value = MockQuerySet([]) + mock_issue.objects.filter.return_value = MockQuerySet([]) + + result = command._aggregate_user_contributions(mock_user, start_date) + + assert isinstance(result, dict) + assert len(result) == 30 + assert all(count == 0 for count in result.values()) + + def test_aggregate_user_contributions_with_data(self): + """Test aggregation with contributions.""" + command = Command() + mock_user = mock.Mock() + mock_user.id = 1 + start_date = datetime(2024, 1, 1, tzinfo=UTC) + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Commit" + ) as mock_commit, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.PullRequest" + ) as mock_pr, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Issue" + ) as mock_issue, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.timezone" + ) as mock_tz, + ): + mock_tz.now.return_value = datetime(2024, 1, 5, tzinfo=UTC) + + mock_commit.objects.filter.return_value = MockQuerySet( + [ + {"date": datetime(2024, 1, 1, 10, 0, tzinfo=UTC).date(), "count": 2}, + {"date": datetime(2024, 1, 2, 10, 0, tzinfo=UTC).date(), "count": 1}, + ] + ) + mock_pr.objects.filter.return_value = MockQuerySet( + [{"date": datetime(2024, 1, 1, 11, 0, tzinfo=UTC).date(), "count": 1}] + ) + mock_issue.objects.filter.return_value = MockQuerySet( + [{"date": datetime(2024, 1, 3, 12, 0, tzinfo=UTC).date(), "count": 3}] + ) + + result = command._aggregate_user_contributions(mock_user, start_date) + + assert isinstance(result, dict) + assert len(result) == 5 + assert result["2024-01-01"] == 3 + assert result["2024-01-02"] == 1 + assert result["2024-01-03"] == 3 + assert result["2024-01-04"] == 0 + assert result["2024-01-05"] == 0 + + def test_handle_with_specific_user_found(self): + """Test handle method with specific user that exists.""" + command = Command() + command.stdout = mock.Mock() + command.style = mock.Mock() + command.style.ERROR = lambda x: x + command.style.SUCCESS = lambda x: x + + mock_user = mock.Mock() + mock_user.login = "testuser" + mock_user.contribution_data = {} + mock_user.save = mock.Mock() + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.User" + ) as mock_user_model, + mock.patch.object( + command, "_aggregate_user_contributions", return_value={"2024-01-01": 5} + ), + ): + mock_queryset = MockQuerySet([mock_user]) + mock_user_model.objects.filter.return_value = mock_queryset + mock_user_model.bulk_save = mock.Mock() + + command.handle(user="testuser", days=365, batch_size=100) + + assert mock_user.contribution_data == {"2024-01-01": 5} + assert mock_user_model.bulk_save.called + call_args = mock_user_model.bulk_save.call_args + assert call_args[0][0] == [mock_user] + assert call_args[1]["fields"] == ["contribution_data"] + + def test_handle_with_specific_user_not_found(self): + """Test handle method with specific user that doesn't exist.""" + command = Command() + command.stdout = mock.Mock() + command.style = mock.Mock() + command.style.ERROR = lambda x: x + command.style.SUCCESS = lambda x: x + + with mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.User" + ) as mock_user_model: + mock_queryset = MockQuerySet([]) + mock_user_model.objects.filter.return_value = mock_queryset + + command.handle(user="nonexistent", days=365, batch_size=100) + + assert any("not found" in str(call) for call in command.stdout.write.call_args_list) + + def test_handle_all_users(self): + """Test handle method processing all users.""" + command = Command() + command.stdout = mock.Mock() + command.style = mock.Mock() + command.style.SUCCESS = lambda x: x + + mock_user1 = mock.Mock() + mock_user1.login = "user1" + mock_user1.contribution_data = {} + mock_user1.save = mock.Mock() + + mock_user2 = mock.Mock() + mock_user2.login = "user2" + mock_user2.contribution_data = {} + mock_user2.save = mock.Mock() + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.User" + ) as mock_user_model, + mock.patch.object( + command, "_aggregate_user_contributions", return_value={"2024-01-01": 5} + ), + ): + mock_queryset = MockQuerySet([mock_user1, mock_user2]) + mock_user_model.objects.filter.return_value = mock_queryset + mock_user_model.bulk_save = mock.Mock() + + command.handle(user=None, days=365, batch_size=100) + + assert mock_user1.contribution_data == {"2024-01-01": 5} + assert mock_user2.contribution_data == {"2024-01-01": 5} + assert mock_user_model.bulk_save.called + call_args = mock_user_model.bulk_save.call_args + assert len(call_args[0][0]) == 2 + assert mock_user1 in call_args[0][0] + assert mock_user2 in call_args[0][0] + assert call_args[1]["fields"] == ["contribution_data"] + + def test_handle_custom_days_parameter(self): + """Test handle method with custom days parameter.""" + command = Command() + command.stdout = mock.Mock() + command.style = mock.Mock() + command.style.SUCCESS = lambda x: x + + mock_user = mock.Mock() + mock_user.contribution_data = {} + mock_user.save = mock.Mock() + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.User" + ) as mock_user_model, + mock.patch.object( + command, "_aggregate_user_contributions", return_value={} + ) as mock_aggregate, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.timezone" + ) as mock_tz, + ): + mock_tz.now.return_value = datetime(2024, 1, 31, tzinfo=UTC) + mock_queryset = MockQuerySet([mock_user]) + mock_user_model.objects.filter.return_value = mock_queryset + mock_user_model.bulk_save = mock.Mock() + + command.handle(user=None, days=90, batch_size=100) + + assert mock_aggregate.called + call_args = mock_aggregate.call_args[0] + start_date = call_args[1] + expected_start = datetime(2024, 1, 31, tzinfo=UTC) - timedelta(days=90) + assert abs((expected_start - start_date).total_seconds()) < 1 + + def test_contribution_data_date_format(self): + """Test that contribution data uses correct date format.""" + command = Command() + mock_user = mock.Mock() + start_date = datetime(2024, 1, 1, tzinfo=UTC) + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Commit" + ) as mock_commit, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.PullRequest" + ) as mock_pr, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Issue" + ) as mock_issue, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.timezone" + ) as mock_tz, + ): + mock_tz.now.return_value = datetime(2024, 1, 3, tzinfo=UTC) + + mock_commit.objects.filter.return_value = MockQuerySet([]) + mock_pr.objects.filter.return_value = MockQuerySet([]) + mock_issue.objects.filter.return_value = MockQuerySet([]) + + result = command._aggregate_user_contributions(mock_user, start_date) + + for date_str in result: + datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) + assert len(date_str) == 10 + assert date_str[4] == "-" + assert date_str[7] == "-" diff --git a/frontend/__tests__/a11y/pages/UserDetails.a11y.test.tsx b/frontend/__tests__/a11y/pages/UserDetails.a11y.test.tsx index 3c680fd334..e93ebe3e5a 100644 --- a/frontend/__tests__/a11y/pages/UserDetails.a11y.test.tsx +++ b/frontend/__tests__/a11y/pages/UserDetails.a11y.test.tsx @@ -44,6 +44,15 @@ jest.mock('utils/helpers/githubHeatmap', () => ({ drawContributions: jest.fn(() => {}), })) +jest.mock('components/ContributionHeatmap', () => { + const MockContributionHeatmap = () =>
Heatmap
+ MockContributionHeatmap.displayName = 'MockContributionHeatmap' + return { + __esModule: true, + default: MockContributionHeatmap, + } +}) + describe('UserDetailsPage Accessibility', () => { it('should have no accessibility violations', async () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ diff --git a/frontend/__tests__/mockData/mockUserDetails.ts b/frontend/__tests__/mockData/mockUserDetails.ts index 4362fa3796..30818e94bf 100644 --- a/frontend/__tests__/mockData/mockUserDetails.ts +++ b/frontend/__tests__/mockData/mockUserDetails.ts @@ -13,6 +13,13 @@ export const mockUserDetailsData = { publicRepositoriesCount: 3, createdAt: 1723002473, contributionsCount: 100, + contributionData: { + '2025-01-01': 5, + '2025-01-02': 3, + '2025-01-03': 8, + '2025-01-04': 0, + '2025-01-05': 12, + }, badges: [ { id: '1', diff --git a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx index dbff39b822..dbddc2d84d 100644 --- a/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx +++ b/frontend/__tests__/unit/components/ContributionHeatmap.test.tsx @@ -626,6 +626,13 @@ describe('ContributionHeatmap', () => { expect(chart).toHaveAttribute('data-height', '195') }) + it('renders medium variant with medium dimensions', () => { + renderWithTheme() + const chart = screen.getByTestId('mock-heatmap-chart') + // Verify medium dimensions (172px height for medium variant) + expect(chart).toHaveAttribute('data-height', '172') + }) + it('renders compact variant with smaller dimensions', () => { renderWithTheme() const chart = screen.getByTestId('mock-heatmap-chart') @@ -651,6 +658,15 @@ describe('ContributionHeatmap', () => { expect(chartContainer).toBeInTheDocument() }) + it('applies medium variant container styling', () => { + const { container } = renderWithTheme( + + ) + // Verify medium variant uses inline-block class + const chartContainer = container.querySelector('.inline-block') + expect(chartContainer).toBeInTheDocument() + }) + it('defaults to default variant when no variant is specified', () => { renderWithTheme() const chart = screen.getByTestId('mock-heatmap-chart') @@ -667,7 +683,7 @@ describe('ContributionHeatmap', () => { rerender( - + ) title = screen.getByText('Test Title') diff --git a/frontend/__tests__/unit/pages/UserDetails.test.tsx b/frontend/__tests__/unit/pages/UserDetails.test.tsx index 7b76c9a112..06e1a951e1 100644 --- a/frontend/__tests__/unit/pages/UserDetails.test.tsx +++ b/frontend/__tests__/unit/pages/UserDetails.test.tsx @@ -5,7 +5,6 @@ import { screen, waitFor } from '@testing-library/react' import { render } from 'wrappers/testUtil' import '@testing-library/jest-dom' import UserDetailsPage from 'app/members/[memberKey]/page' -import { drawContributions, fetchHeatmapData } from 'utils/helpers/githubHeatmap' // Mock Apollo Client jest.mock('@apollo/client/react', () => ({ @@ -53,11 +52,14 @@ jest.mock('next/navigation', () => ({ useParams: () => ({ memberKey: 'test-user' }), })) -// Mock GitHub heatmap utilities -jest.mock('utils/helpers/githubHeatmap', () => ({ - fetchHeatmapData: jest.fn(), - drawContributions: jest.fn(() => {}), -})) +jest.mock('components/ContributionHeatmap', () => { + const MockContributionHeatmap = () =>
Heatmap
+ MockContributionHeatmap.displayName = 'MockContributionHeatmap' + return { + __esModule: true, + default: MockContributionHeatmap, + } +}) jest.mock('@heroui/toast', () => ({ addToast: jest.fn(), @@ -70,10 +72,6 @@ describe('UserDetailsPage', () => { loading: false, error: null, }) - ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ - contributions: { years: [{ year: '2023' }] }, - }) - ;(drawContributions as jest.Mock).mockImplementation(() => {}) }) afterEach(() => { @@ -271,35 +269,35 @@ describe('UserDetailsPage', () => { error: null, loading: false, }) - ;(fetchHeatmapData as jest.Mock).mockResolvedValue({ - years: [{ year: '2023' }], // Provide years data to satisfy condition in component - }) render() - // Wait for useEffect to process the fetchHeatmapData result await waitFor(() => { - const heatmapContainer = screen - .getByAltText('Heatmap Background') - .closest(String.raw`div.hidden.lg\:block`) - expect(heatmapContainer).toBeInTheDocument() - expect(heatmapContainer).toHaveClass('hidden') - expect(heatmapContainer).toHaveClass('lg:block') + const heatmap = screen.getByTestId('contribution-heatmap') + expect(heatmap).toBeInTheDocument() }) }) test('handles contribution heatmap loading error correctly', async () => { + const dataWithoutContributions = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + contributionData: null, + }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ - data: mockUserDetailsData, + data: dataWithoutContributions, error: null, + loading: false, }) - ;(fetchHeatmapData as jest.Mock).mockResolvedValue(null) render() await waitFor(() => { - const heatmapTitle = screen.queryByText('Contribution Heatmap') - expect(heatmapTitle).not.toBeInTheDocument() + const heatmap = screen.queryByTestId('contribution-heatmap') + expect(heatmap).not.toBeInTheDocument() }) }) @@ -307,6 +305,7 @@ describe('UserDetailsPage', () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ data: mockUserDetailsData, error: null, + loading: false, }) render() @@ -805,4 +804,50 @@ describe('UserDetailsPage', () => { }) }) }) + + describe('Contribution Heatmap', () => { + test('does not render heatmap when user has empty contribution data', async () => { + const dataWithEmptyContributions = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + contributionData: {}, + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithEmptyContributions, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.queryByTestId('contribution-heatmap')).not.toBeInTheDocument() + }) + }) + + test('does not render heatmap when user has null contribution data', async () => { + const dataWithoutContributions = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + contributionData: null, + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithoutContributions, + loading: false, + error: null, + }) + + render() + + await waitFor(() => { + expect(screen.queryByTestId('contribution-heatmap')).not.toBeInTheDocument() + }) + }) + }) }) diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx index f7c95d2175..962008aed4 100644 --- a/frontend/src/app/members/[memberKey]/page.tsx +++ b/frontend/src/app/members/[memberKey]/page.tsx @@ -3,24 +3,20 @@ import { useQuery } from '@apollo/client/react' import Image from 'next/image' import Link from 'next/link' import { useParams } from 'next/navigation' -import { useTheme } from 'next-themes' -import React, { useState, useEffect, useRef } from 'react' +import React, { useEffect, useMemo } from 'react' import { FaCodeMerge, FaFolderOpen, FaPersonWalkingArrowRight, FaUserPlus } from 'react-icons/fa6' import { handleAppError, ErrorDisplay } from 'app/global-error' import { GetUserDataDocument } from 'types/__generated__/userQueries.generated' import { Badge } from 'types/badge' import { formatDate } from 'utils/dateFormatter' -import { drawContributions, fetchHeatmapData, HeatmapData } from 'utils/helpers/githubHeatmap' import Badges from 'components/Badges' import DetailsCard from 'components/CardDetailsPage' +import ContributionHeatmap from 'components/ContributionHeatmap' import MemberDetailsPageSkeleton from 'components/skeletons/MemberDetailsPageSkeleton' const UserDetailsPage: React.FC = () => { const { memberKey } = useParams<{ memberKey: string }>() - const [data, setData] = useState({} as HeatmapData) - const [username, setUsername] = useState('') - const [isPrivateContributor, setIsPrivateContributor] = useState(false) const { data: graphQLData, @@ -43,20 +39,25 @@ const UserDetailsPage: React.FC = () => { } }, [graphQLRequestError, memberKey]) - useEffect(() => { - const fetchData = async () => { - const result = await fetchHeatmapData(memberKey) - if (!result) { - setIsPrivateContributor(true) - return - } - if (result?.contributions) { - setUsername(memberKey) - setData(result as HeatmapData) - } + const contributionData: Record = useMemo(() => { + if (user?.contributionData && typeof user.contributionData === 'object') { + return user.contributionData as Record + } + return {} + }, [user?.contributionData]) + + const dateRange = useMemo(() => { + const dates = Object.keys(contributionData).sort((a, b) => a.localeCompare(b)) + if (dates.length === 0) { + return { startDate: '', endDate: '' } } - fetchData() - }, [memberKey, user]) + return { + startDate: dates[0], + endDate: dates.at(-1) ?? '', + } + }, [contributionData]) + + const hasContributionData = Object.keys(contributionData).length > 0 const formattedBio = user?.bio?.split(' ').map((word) => { const mentionMatch = word.match(/^@([\w-]+(?:\.[\w-]+)*)([^\w@])?$/) @@ -128,61 +129,6 @@ const UserDetailsPage: React.FC = () => { { icon: FaCodeMerge, value: user?.contributionsCount || 0, unit: 'Contribution' }, ] - const Heatmap = () => { - const canvasRef = useRef(null) - const [imgSrc, setImgSrc] = useState('') - const { resolvedTheme } = useTheme() - const isDarkMode = (resolvedTheme ?? 'light') === 'dark' - - useEffect(() => { - if (canvasRef.current && data?.years?.length) { - drawContributions(canvasRef.current, { - data, - username, - themeName: isDarkMode ? 'dark' : 'light', - }) - const imageURL = canvasRef.current.toDataURL() - setImgSrc(imageURL) - } else { - setImgSrc('') - } - }, [isDarkMode]) - - return ( -
-
- - {imgSrc ? ( -
- Contribution Heatmap -
- ) : ( -
- Heatmap Background -
-
- )} -
-
- ) - } - const UserSummary = () => (
{ src={user?.avatarUrl || '/placeholder.svg'} alt={user?.name || user?.login || 'User Avatar'} /> -
+
{

{formattedBio}

- {!isPrivateContributor && ( -
- + {hasContributionData && dateRange.startDate && dateRange.endDate && ( +
+
+ +
)}
diff --git a/frontend/src/components/ContributionHeatmap.tsx b/frontend/src/components/ContributionHeatmap.tsx index 2da4804c12..903b859235 100644 --- a/frontend/src/components/ContributionHeatmap.tsx +++ b/frontend/src/components/ContributionHeatmap.tsx @@ -249,7 +249,7 @@ interface ContributionHeatmapProps { endDate: string title?: string unit?: string - variant?: 'default' | 'compact' + variant?: 'default' | 'medium' | 'compact' } const ContributionHeatmap: React.FC = ({ @@ -262,7 +262,6 @@ const ContributionHeatmap: React.FC = ({ }) => { const { theme } = useTheme() const isDarkMode = theme === 'dark' - const isCompact = variant === 'compact' const { heatmapSeries } = useMemo( () => generateHeatmapSeries(startDate, endDate, contributionData), @@ -274,21 +273,34 @@ const ContributionHeatmap: React.FC = ({ const calculateChartWidth = useMemo(() => { const weeksCount = heatmapSeries[0]?.data?.length || 0 - if (isCompact) { + if (variant === 'compact') { const pixelPerWeek = 13.4 const padding = 40 const calculatedWidth = weeksCount * pixelPerWeek + padding return Math.max(400, calculatedWidth) } + if (variant === 'medium') { + const pixelPerWeek = 16 + const padding = 45 + const calculatedWidth = weeksCount * pixelPerWeek + padding + return Math.max(500, calculatedWidth) + } + const pixelPerWeek = 19.5 const padding = 50 const calculatedWidth = weeksCount * pixelPerWeek + padding return Math.max(600, calculatedWidth) - }, [heatmapSeries, isCompact]) + }, [heatmapSeries, variant]) const chartWidth = calculateChartWidth + const getChartHeight = () => { + if (variant === 'compact') return 150 + if (variant === 'medium') return 172 + return 195 + } + return (
{title && ( @@ -316,7 +328,7 @@ const ContributionHeatmap: React.FC = ({
; bio: Scalars['String']['output']; company: Scalars['String']['output']; + contributionData?: Maybe; contributionsCount: Scalars['Int']['output']; createdAt: Scalars['Float']['output']; email: Scalars['String']['output']; diff --git a/frontend/src/types/__generated__/userQueries.generated.ts b/frontend/src/types/__generated__/userQueries.generated.ts index 9c695619d9..35ac76ee35 100644 --- a/frontend/src/types/__generated__/userQueries.generated.ts +++ b/frontend/src/types/__generated__/userQueries.generated.ts @@ -13,7 +13,7 @@ export type GetUserDataQueryVariables = Types.Exact<{ }>; -export type GetUserDataQuery = { recentIssues: Array<{ __typename: 'IssueNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string }>, recentMilestones: Array<{ __typename: 'MilestoneNode', id: string, title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string }>, recentReleases: Array<{ __typename: 'ReleaseNode', id: string, isPreRelease: boolean, name: string, publishedAt: any | null, organizationName: string | null, repositoryName: string | null, tagName: string, url: string }>, topContributedRepositories: Array<{ __typename: 'RepositoryNode', id: string, contributorsCount: number, forksCount: number, isArchived: boolean, key: string, name: string, openIssuesCount: number, starsCount: number, subscribersCount: number, url: string, organization: { __typename: 'OrganizationNode', id: string, login: string } | null }>, user: { __typename: 'UserNode', avatarUrl: string, badgeCount: number, bio: string, company: string, contributionsCount: number, createdAt: number, email: string, followersCount: number, followingCount: number, id: string, issuesCount: number, location: string, login: string, name: string, publicRepositoriesCount: number, releasesCount: number, updatedAt: number, url: string, badges: Array<{ __typename: 'BadgeNode', cssClass: string, description: string, id: string, name: string, weight: number }> } | null }; +export type GetUserDataQuery = { recentIssues: Array<{ __typename: 'IssueNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string }>, recentMilestones: Array<{ __typename: 'MilestoneNode', id: string, title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string }>, recentReleases: Array<{ __typename: 'ReleaseNode', id: string, isPreRelease: boolean, name: string, publishedAt: any | null, organizationName: string | null, repositoryName: string | null, tagName: string, url: string }>, topContributedRepositories: Array<{ __typename: 'RepositoryNode', id: string, contributorsCount: number, forksCount: number, isArchived: boolean, key: string, name: string, openIssuesCount: number, starsCount: number, subscribersCount: number, url: string, organization: { __typename: 'OrganizationNode', id: string, login: string } | null }>, user: { __typename: 'UserNode', avatarUrl: string, badgeCount: number, bio: string, company: string, contributionData: any | null, contributionsCount: number, createdAt: number, email: string, followersCount: number, followingCount: number, id: string, issuesCount: number, location: string, login: string, name: string, publicRepositoriesCount: number, releasesCount: number, updatedAt: number, url: string, badges: Array<{ __typename: 'BadgeNode', cssClass: string, description: string, id: string, name: string, weight: number }> } | null }; export type GetUserMetadataQueryVariables = Types.Exact<{ key: Types.Scalars['String']['input']; @@ -24,5 +24,5 @@ export type GetUserMetadataQuery = { user: { __typename: 'UserNode', badgeCount: export const GetLeaderDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLeaderData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"badgeCount"}},{"kind":"Field","name":{"kind":"Name","value":"badges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cssClass"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"weight"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetUserDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isPreRelease"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributedRepositories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"isArchived"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"organization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"subscribersCount"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"badgeCount"}},{"kind":"Field","name":{"kind":"Name","value":"badges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cssClass"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"weight"}}]}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"contributionsCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"followersCount"}},{"kind":"Field","name":{"kind":"Name","value":"followingCount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publicRepositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"releasesCount"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; +export const GetUserDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isPreRelease"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributedRepositories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"isArchived"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"organization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"subscribersCount"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"badgeCount"}},{"kind":"Field","name":{"kind":"Name","value":"badges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cssClass"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"weight"}}]}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"contributionData"}},{"kind":"Field","name":{"kind":"Name","value":"contributionsCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"followersCount"}},{"kind":"Field","name":{"kind":"Name","value":"followingCount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publicRepositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"releasesCount"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; export const GetUserMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserMetadata"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"badgeCount"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file