diff --git a/backend/Makefile b/backend/Makefile index 5de94c03e0..cab4cee5a1 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -45,6 +45,10 @@ github-update-project-related-repositories: @echo "Updating OWASP project related GitHub repositories" @CMD="python manage.py github_update_project_related_repositories" $(MAKE) exec-backend-command +github-update-users: + @echo "Updating GitHub users" + @CMD="python manage.py github_update_users" $(MAKE) exec-backend-command + index-data: @echo "Indexing Nest data" @CMD="python manage.py algolia_reindex" $(MAKE) exec-backend-command @@ -155,6 +159,7 @@ update-data: \ owasp-scrape-committees \ owasp-scrape-projects \ github-update-project-related-repositories \ + github-update-users \ owasp-aggregate-projects \ owasp-update-events \ owasp-update-sponsors diff --git a/backend/apps/github/graphql/nodes/user.py b/backend/apps/github/graphql/nodes/user.py index 9db7108acf..258c24c3df 100644 --- a/backend/apps/github/graphql/nodes/user.py +++ b/backend/apps/github/graphql/nodes/user.py @@ -28,6 +28,7 @@ class Meta: "avatar_url", "bio", "company", + "contributions_count", "email", "followers_count", "following_count", diff --git a/backend/apps/github/management/commands/github_update_users.py b/backend/apps/github/management/commands/github_update_users.py new file mode 100644 index 0000000000..925b247019 --- /dev/null +++ b/backend/apps/github/management/commands/github_update_users.py @@ -0,0 +1,55 @@ +"""A command to update GitHub users.""" + +import logging + +from django.core.management.base import BaseCommand +from django.db.models import Sum + +from apps.common.models import BATCH_SIZE +from apps.github.models.repository_contributor import RepositoryContributor +from apps.github.models.user import User + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Update GitHub users." + + def add_arguments(self, parser): + """Add command-line arguments to the parser. + + Args: + parser (argparse.ArgumentParser): The argument parser instance. + + """ + parser.add_argument("--offset", default=0, required=False, type=int) + + def handle(self, *args, **options): + """Handle the command execution. + + Args: + *args: Variable length argument list. + **options: Arbitrary keyword arguments containing command options. + + """ + active_users = User.objects.order_by("-created_at") + active_users_count = active_users.count() + offset = options["offset"] + user_contributions = { + item["user_id"]: item["total_contributions"] + for item in RepositoryContributor.objects.values("user_id").annotate( + total_contributions=Sum("contributions_count") + ) + } + users = [] + for idx, user in enumerate(active_users[offset:]): + prefix = f"{idx + offset + 1} of {active_users_count - offset}" + print(f"{prefix:<10} {user.title}") + + user.contributions_count = user_contributions.get(user.id, 0) + users.append(user) + + if not len(users) % BATCH_SIZE: + User.bulk_save(users, fields=("contributions_count",)) + + User.bulk_save(users, fields=("contributions_count",)) diff --git a/backend/apps/github/migrations/0021_user_contributions_count.py b/backend/apps/github/migrations/0021_user_contributions_count.py new file mode 100644 index 0000000000..9d717cf51f --- /dev/null +++ b/backend/apps/github/migrations/0021_user_contributions_count.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-04-07 07:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0020_repositorycontributor_user_contrib_idx"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="contributions_count", + field=models.IntegerField(default=0, verbose_name="Contributions count"), + ), + ] diff --git a/backend/apps/github/migrations/0022_merge_20250417_1000.py b/backend/apps/github/migrations/0022_merge_20250417_1000.py new file mode 100644 index 0000000000..a0dca42675 --- /dev/null +++ b/backend/apps/github/migrations/0022_merge_20250417_1000.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2 on 2025-04-17 10:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0021_release_release_published_at_idx"), + ("github", "0021_user_contributions_count"), + ] + + operations = [] diff --git a/backend/apps/github/migrations/0023_alter_user_contributions_count.py b/backend/apps/github/migrations/0023_alter_user_contributions_count.py new file mode 100644 index 0000000000..67d5927f3d --- /dev/null +++ b/backend/apps/github/migrations/0023_alter_user_contributions_count.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2 on 2025-04-20 02:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0022_merge_20250417_1000"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="contributions_count", + field=models.PositiveIntegerField(default=0, verbose_name="Contributions count"), + ), + ] diff --git a/backend/apps/github/models/mixins/user.py b/backend/apps/github/models/mixins/user.py index 1d2cf75dc0..be2a95e9a9 100644 --- a/backend/apps/github/models/mixins/user.py +++ b/backend/apps/github/models/mixins/user.py @@ -1,7 +1,5 @@ """GitHub user model mixins for index-related functionality.""" -from django.db.models import Sum - ISSUES_LIMIT = 6 RELEASES_LIMIT = 6 TOP_REPOSITORY_CONTRIBUTORS_LIMIT = 6 @@ -108,14 +106,7 @@ def idx_contributions(self): @property def idx_contributions_count(self): """Return contributions count for indexing.""" - from apps.github.models.repository_contributor import RepositoryContributor - - return ( - RepositoryContributor.objects.by_humans() - .filter(user=self) - .aggregate(total_contributions=Sum("contributions_count"))["total_contributions"] - or 0 - ) + return self.contributions_count @property def idx_issues(self): diff --git a/backend/apps/github/models/user.py b/backend/apps/github/models/user.py index 8ec3fe6d4b..d384a3a9b8 100644 --- a/backend/apps/github/models/user.py +++ b/backend/apps/github/models/user.py @@ -2,7 +2,7 @@ from django.db import models -from apps.common.models import TimestampedModel +from apps.common.models import BulkSaveModel, TimestampedModel from apps.github.constants import GITHUB_GHOST_USER_LOGIN, OWASP_FOUNDATION_LOGIN from apps.github.models.common import GenericUserModel, NodeModel from apps.github.models.mixins.user import UserIndexMixin @@ -22,6 +22,10 @@ class Meta: is_bot = models.BooleanField(verbose_name="Is bot", default=False) + contributions_count = models.PositiveIntegerField( + verbose_name="Contributions count", default=0 + ) + def __str__(self): """Return a human-readable representation of the user. @@ -74,6 +78,11 @@ def from_github(self, gh_user): self.is_bot = gh_user.type == "Bot" + @staticmethod + def bulk_save(users, fields=None): + """Bulk save users.""" + BulkSaveModel.bulk_save(User, users, fields=fields) + @staticmethod def get_non_indexable_logins(): """Get logins that should not be indexed. diff --git a/backend/tests/apps/github/graphql/nodes/user_test.py b/backend/tests/apps/github/graphql/nodes/user_test.py index b2a1327553..7ea6df4739 100644 --- a/backend/tests/apps/github/graphql/nodes/user_test.py +++ b/backend/tests/apps/github/graphql/nodes/user_test.py @@ -19,6 +19,7 @@ def test_meta_configuration(self): "avatar_url", "bio", "company", + "contributions_count", "created_at", "email", "followers_count", diff --git a/frontend/__tests__/e2e/pages/UserDetails.spec.ts b/frontend/__tests__/e2e/pages/UserDetails.spec.ts index bf9f566654..71b3c6b753 100644 --- a/frontend/__tests__/e2e/pages/UserDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/UserDetails.spec.ts @@ -35,6 +35,7 @@ test.describe('User Details Page', () => { await expect(page.getByText('10 Followers')).toBeVisible() await expect(page.getByText('5 Following')).toBeVisible() await expect(page.getByText('3 Repositories')).toBeVisible() + await expect(page.getByText('100 Contributions')).toBeVisible() }) test('should have user issues', async ({ page }) => { diff --git a/frontend/__tests__/unit/data/mockUserDetails.ts b/frontend/__tests__/unit/data/mockUserDetails.ts index 3024a4ae64..a673a50ada 100644 --- a/frontend/__tests__/unit/data/mockUserDetails.ts +++ b/frontend/__tests__/unit/data/mockUserDetails.ts @@ -12,6 +12,7 @@ export const mockUserDetailsData = { followingCount: 5, publicRepositoriesCount: 3, createdAt: 1723002473, + contributionsCount: 100, }, recentIssues: [ { diff --git a/frontend/__tests__/unit/pages/UserDetails.test.tsx b/frontend/__tests__/unit/pages/UserDetails.test.tsx index f628c2674f..8c47669d50 100644 --- a/frontend/__tests__/unit/pages/UserDetails.test.tsx +++ b/frontend/__tests__/unit/pages/UserDetails.test.tsx @@ -179,6 +179,9 @@ describe('UserDetailsPage', () => { const repositoriesCount = screen.getByText('3 Repositories') expect(repositoriesCount).toBeInTheDocument() + + const contributionsCount = screen.getByText('100 Contributions') + expect(contributionsCount).toBeInTheDocument() }) }) @@ -330,11 +333,10 @@ describe('UserDetailsPage', () => { ...mockUserDetailsData, user: { ...mockUserDetailsData.user, + contributionsCount: 0, followersCount: 0, followingCount: 0, publicRepositoriesCount: 0, - issuesCount: 0, - releasesCount: 0, }, } ;(useQuery as jest.Mock).mockReturnValue({ @@ -348,8 +350,7 @@ describe('UserDetailsPage', () => { expect(screen.getByText('No Followers')).toBeInTheDocument() expect(screen.getByText('No Followings')).toBeInTheDocument() expect(screen.getByText('No Repositories')).toBeInTheDocument() - expect(screen.getByText('No Issues')).toBeInTheDocument() - expect(screen.getByText('No Releases')).toBeInTheDocument() + expect(screen.getByText('No Contributions')).toBeInTheDocument() }) }) @@ -371,7 +372,7 @@ describe('UserDetailsPage', () => { render() await waitFor(() => { - expect(screen.getAllByText('Not provided').length).toBe(3) + expect(screen.getAllByText('N/A').length).toBe(3) }) }) }) diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx index 5efc1146ec..f0dac9d2f2 100644 --- a/frontend/src/app/members/[memberKey]/page.tsx +++ b/frontend/src/app/members/[memberKey]/page.tsx @@ -1,10 +1,9 @@ 'use client' import { useQuery } from '@apollo/client' import { - faCircleExclamation, + faCodeMerge, faFolderOpen, faPersonWalkingArrowRight, - faTag, faUserPlus, } from '@fortawesome/free-solid-svg-icons' import Image from 'next/image' @@ -173,18 +172,10 @@ const UserDetailsPage: React.FC = () => { } const userDetails = [ - { - label: 'GitHub Profile', - value: ( - - @{user?.login} - - ), - }, { label: 'Joined', value: user?.createdAt ? formatDate(user.createdAt) : 'Not available' }, - { label: 'Email', value: user?.email || 'Not provided' }, - { label: 'Company', value: user?.company || 'Not provided' }, - { label: 'Location', value: user?.location || 'Not provided' }, + { label: 'Email', value: user?.email || 'N/A' }, + { label: 'Company', value: user?.company || 'N/A' }, + { label: 'Location', value: user?.location || 'N/A' }, ] const userStats = [ @@ -196,8 +187,7 @@ const UserDetailsPage: React.FC = () => { unit: 'Repository', value: user?.publicRepositoriesCount ?? 0, }, - { icon: faCircleExclamation, value: user?.issuesCount || 0, unit: 'Issue' }, - { icon: faTag, value: user?.releasesCount || 0, unit: 'Release' }, + { icon: faCodeMerge, value: user?.contributionsCount || 0, unit: 'Contribution' }, ] const Heatmap = () => ( diff --git a/frontend/src/server/queries/userQueries.ts b/frontend/src/server/queries/userQueries.ts index 2509012d82..ac216589ed 100644 --- a/frontend/src/server/queries/userQueries.ts +++ b/frontend/src/server/queries/userQueries.ts @@ -42,6 +42,7 @@ export const GET_USER_DATA = gql` avatarUrl bio company + contributionsCount createdAt email followersCount diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index b94b78a22e..67c2145b10 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -4,6 +4,7 @@ export type user = { avatar_url: string bio: string company: string + contributions_count: number created_at: number email: string followers_count: number @@ -56,6 +57,7 @@ export type User = { releases?: Release[] releases_count?: number url: string + contributions_count: number } export interface UserDetailsProps { @@ -76,6 +78,7 @@ export interface UserDetailsProps { releasesCount: number topRepositories: RepositoryCardProps[] url: string + contributionsCount: number } export interface PullRequestsType {