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 {