diff --git a/website/migrations/0261_githubcomment.py b/website/migrations/0261_githubcomment.py new file mode 100644 index 0000000000..73fbcf0222 --- /dev/null +++ b/website/migrations/0261_githubcomment.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.9 on 2025-12-24 13:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("website", "0260_add_username_to_slackbotactivity"), + ] + + operations = [ + migrations.CreateModel( + name="GitHubComment", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("comment_id", models.BigIntegerField(unique=True)), + ("body", models.TextField()), + ("comment_type", models.CharField(max_length=50)), + ("created_at", models.DateTimeField()), + ("updated_at", models.DateTimeField()), + ("url", models.URLField()), + ( + "contributor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="github_comments", + to="website.contributor", + ), + ), + ( + "github_issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="comments", to="website.githubissue" + ), + ), + ( + "repo", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="github_comments", + to="website.repo", + ), + ), + ( + "user_profile", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="github_comments", + to="website.userprofile", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/website/models.py b/website/models.py index dd3d3747bb..a2a40b5dae 100644 --- a/website/models.py +++ b/website/models.py @@ -2328,6 +2328,43 @@ def add_labels(self, labels): return False +class GitHubComment(models.Model): + github_issue = models.ForeignKey(GitHubIssue, on_delete=models.CASCADE, related_name="comments") + user_profile = models.ForeignKey( + UserProfile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="github_comments", + ) + contributor = models.ForeignKey( + Contributor, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="github_comments", + ) + repo = models.ForeignKey( + Repo, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="github_comments", + ) + comment_id = models.BigIntegerField(unique=True) + body = models.TextField() + comment_type = models.CharField(max_length=50) + created_at = models.DateTimeField() + updated_at = models.DateTimeField() + url = models.URLField() + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"Comment {self.comment_id} - {self.comment_type}" + + class BaconEarning(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) tokens_earned = models.DecimalField(max_digits=10, decimal_places=2, default=0.00) # Tokens earned by user diff --git a/website/templates/leaderboard_global.html b/website/templates/leaderboard_global.html index f94b58e172..05aae29bfb 100644 --- a/website/templates/leaderboard_global.html +++ b/website/templates/leaderboard_global.html @@ -23,9 +23,9 @@

Global Leaderboard

-
+
-
+
Points Leaderboard
{% if not leaderboard %} @@ -77,7 +77,7 @@

Global Leaderboard

{% endif %}
-
+
Pull Request Leaderboard
Last 6 months
@@ -125,7 +125,7 @@

Global Leaderboard

{% endif %}
-
+
Code Review Leaderboard
Last 6 months
@@ -176,8 +176,8 @@

Global Leaderboard

-
-
+
+
Top Visitors
{% if top_visitors %} @@ -187,7 +187,7 @@

Global Leaderboard

{% if profile.avatar %} {{ profile.user.username }} @@ -214,7 +214,50 @@

Global Leaderboard

{% endif %}
-
+
+
+ GitHub Comment Leaderboard +
Last 6 months
+
+
+ {% if comment_leaderboard %} +
+ {% for leader in comment_leaderboard %} +
+
+ {% if leader.contributor__avatar_url %} + {{ leader.contributor__name }} + {% else %} + {{ leader.contributor__name|default:'Unknown User' }} + {% endif %} + {{ leader.contributor__name|default:'Unknown User' }} + + + +
+ {{ leader.total_comments }} Comments +
+
+ {% endfor %} +
+ {% else %} +

No comment data available!

+ {% endif %} +
+
+
Issue Bounties
{% if not issue_bounties %} @@ -223,7 +266,7 @@

Global Leaderboard

{% endif %}
-
+
Bug Bounties
{% if not bug_bounties %} diff --git a/website/tests/test_webhook_comments.py b/website/tests/test_webhook_comments.py new file mode 100644 index 0000000000..f7458b7f9d --- /dev/null +++ b/website/tests/test_webhook_comments.py @@ -0,0 +1,110 @@ +import json + +from django.test import Client, TestCase +from django.urls import reverse + +from website.models import GitHubComment, GitHubIssue, Repo, User, UserProfile + + +class GitHubCommentWebhookTest(TestCase): + def setUp(self): + self.client = Client() + self.webhook_url = reverse("github-webhook") + self.repo = Repo.objects.create(repo_url="https://github.com/owner/repo") + self.user = User.objects.create(username="testuser") + self.user_profile, created = UserProfile.objects.get_or_create( + user=self.user, defaults={"github_url": "https://github.com/testuser"} + ) + if not created: + self.user_profile.github_url = "https://github.com/testuser" + self.user_profile.save() + self.issue = GitHubIssue.objects.create( + issue_id=1, + repo=self.repo, + title="Test PR", + type="pull_request", + state="open", + created_at="2023-01-01T00:00:00Z", + updated_at="2023-01-01T00:00:00Z", + ) + + def test_issue_comment_on_pr(self): + payload = { + "action": "created", + "issue": { + "number": 1, + "pull_request": {}, # Indicates it is a PR + }, + "comment": { + "id": 1001, + "body": "This is a comment", + "created_at": "2023-01-01T12:00:00Z", + "updated_at": "2023-01-01T12:00:00Z", + "html_url": "https://github.com/owner/repo/issues/1#issuecomment-1001", + }, + "repository": {"html_url": "https://github.com/owner/repo", "full_name": "owner/repo"}, + "sender": {"login": "testuser", "html_url": "https://github.com/testuser", "type": "User"}, + } + response = self.client.post( + self.webhook_url, + data=json.dumps(payload), + content_type="application/json", + HTTP_X_GITHUB_EVENT="issue_comment", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(GitHubComment.objects.filter(comment_id=1001).exists()) + comment = GitHubComment.objects.get(comment_id=1001) + self.assertEqual(comment.github_issue, self.issue) + self.assertEqual(comment.user_profile, self.user_profile) + + def test_issue_comment_not_on_pr(self): + payload = { + "action": "created", + "issue": { + "number": 1, + # No pull_request key + }, + "comment": { + "id": 1002, + "body": "This is a comment on an issue", + "created_at": "2023-01-01T12:00:00Z", + "updated_at": "2023-01-01T12:00:00Z", + "html_url": "https://github.com/owner/repo/issues/1#issuecomment-1002", + }, + "repository": {"html_url": "https://github.com/owner/repo", "full_name": "owner/repo"}, + "sender": {"login": "testuser", "type": "User"}, + } + response = self.client.post( + self.webhook_url, + data=json.dumps(payload), + content_type="application/json", + HTTP_X_GITHUB_EVENT="issue_comment", + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(GitHubComment.objects.filter(comment_id=1002).exists()) + + def test_bot_comment_ignored(self): + payload = { + "action": "created", + "issue": { + "number": 1, + "pull_request": {}, + }, + "comment": { + "id": 1003, + "body": "I am a bot", + "created_at": "2023-01-01T12:00:00Z", + "updated_at": "2023-01-01T12:00:00Z", + "html_url": "https://github.com/owner/repo/issues/1#issuecomment-1003", + }, + "repository": {"html_url": "https://github.com/owner/repo", "full_name": "owner/repo"}, + "sender": {"login": "copilot", "type": "Bot", "id": 12345}, + } + response = self.client.post( + self.webhook_url, + data=json.dumps(payload), + content_type="application/json", + HTTP_X_GITHUB_EVENT="issue_comment", + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(GitHubComment.objects.filter(comment_id=1003).exists()) diff --git a/website/views/user.py b/website/views/user.py index 41b8ca7e73..009a1a9dd5 100644 --- a/website/views/user.py +++ b/website/views/user.py @@ -17,7 +17,7 @@ from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache from django.core.mail import send_mail -from django.db.models import Count, F, Q, Sum +from django.db.models import Count, F, Max, Q, Sum from django.db.models.functions import ExtractMonth from django.dispatch import receiver from django.http import Http404, HttpResponse, HttpResponseNotFound, JsonResponse @@ -43,6 +43,7 @@ Contributor, ContributorStats, Domain, + GitHubComment, GitHubIssue, GitHubReview, Hunt, @@ -634,6 +635,34 @@ def get_context_data(self, *args, **kwargs): ) context["code_review_leaderboard"] = reviewed_pr_leaderboard + # Comment Leaderboard - Use contributor data directly + # PR comments from last 6 months, ordered by recent activity + comment_leaderboard = ( + GitHubComment.objects.filter( + github_issue__type="pull_request", + created_at__gte=since_date, + contributor__isnull=False, # Only comments with contributors + ) + .filter( + Q(github_issue__repo__repo_url__startswith="https://github.com/OWASP-BLT/") + | Q(github_issue__repo__repo_url__startswith="https://github.com/owasp-blt/") + ) + .exclude(contributor__name__icontains="copilot") # Exclude copilot contributors + .exclude(contributor__name__icontains="dependabot") # Exclude dependabot + .select_related("contributor") + .values( + "contributor__name", + "contributor__github_url", + "contributor__avatar_url", + ) + .annotate( + total_comments=Count("id"), + latest_comment=Max("created_at"), # Get the most recent comment date + ) + .order_by("-total_comments", "-latest_comment")[:10] # Order by comment count, then by recent activity + ) + context["comment_leaderboard"] = comment_leaderboard + # Top visitors leaderboard top_visitors = ( UserProfile.objects.select_related("user") @@ -1295,6 +1324,8 @@ def github_webhook(request): "push": handle_push_event, "pull_request_review": handle_review_event, "issues": handle_issue_event, + "issue_comment": handle_issue_comment_event, + "pull_request_review_comment": handle_review_comment_event, "status": handle_status_event, "fork": handle_fork_event, "create": handle_create_event, @@ -1571,6 +1602,116 @@ def handle_create_event(payload): return JsonResponse({"status": "success"}, status=200) +def handle_issue_comment_event(payload): + action = payload.get("action") + if action not in ["created", "edited"]: + return JsonResponse({"status": "ignored"}, status=200) + + comment_data = payload.get("comment", {}) + issue_data = payload.get("issue", {}) + sender = payload.get("sender", {}) + repo_data = payload.get("repository", {}) + + # Check if this comment is on a PR (GitHub API returns 'pull_request' key in issue object for PRs) + if "pull_request" not in issue_data: + return JsonResponse({"status": "ignored", "reason": "Not a PR comment"}, status=200) + + # Bot filtering + login = sender.get("login", "") + is_bot = sender.get("type") == "Bot" or "bot" in login.lower() + bots = [ + "copilot", + "coderabbitai", + "github-actions[bot]", + "dependabot", + "sonarcloud", + "vercel", + "netlify", + ] + if login.lower() in bots or (is_bot and login.lower() not in ["..."]): # Add exceptions if any bot is allowed + return JsonResponse({"status": "ignored", "reason": "Bot comment"}, status=200) + + # Find Issue + issue_number = issue_data.get("number") + repo_html_url = repo_data.get("html_url") + try: + repo = Repo.objects.get(repo_url=repo_html_url) + github_issue = GitHubIssue.objects.get(issue_id=issue_number, repo=repo) + except (Repo.DoesNotExist, GitHubIssue.DoesNotExist): + return JsonResponse({"status": "ignored", "reason": "Repo or Issue not found"}, status=200) + + # Find/Create User Profile + user_profile = UserProfile.objects.filter(github_url=sender.get("html_url")).first() + + # Create Comment + GitHubComment.objects.update_or_create( + comment_id=comment_data.get("id"), + defaults={ + "github_issue": github_issue, + "user_profile": user_profile, + "body": comment_data.get("body", ""), + "comment_type": "issue_comment", + "created_at": dateutil_parser.parse(comment_data.get("created_at")), + "updated_at": dateutil_parser.parse(comment_data.get("updated_at")), + "url": comment_data.get("html_url"), + }, + ) + return JsonResponse({"status": "success"}, status=200) + + +def handle_review_comment_event(payload): + action = payload.get("action") + if action not in ["created", "edited"]: + return JsonResponse({"status": "ignored"}, status=200) + + comment_data = payload.get("comment", {}) + pr_data = payload.get("pull_request", {}) + sender = payload.get("sender", {}) + repo_data = payload.get("repository", {}) + + # Bot filtering + login = sender.get("login", "") + is_bot = sender.get("type") == "Bot" or "bot" in login.lower() + bots = [ + "copilot", + "coderabbitai", + "github-actions[bot]", + "dependabot", + "sonarcloud", + "vercel", + "netlify", + ] + if login.lower() in bots or is_bot: + return JsonResponse({"status": "ignored", "reason": "Bot comment"}, status=200) + + # Find Issue (PR is also a GitHubIssue) + pr_number = pr_data.get("number") + repo_html_url = repo_data.get("html_url") + try: + repo = Repo.objects.get(repo_url=repo_html_url) + github_issue = GitHubIssue.objects.get(issue_id=pr_number, repo=repo) + except (Repo.DoesNotExist, GitHubIssue.DoesNotExist): + return JsonResponse({"status": "ignored", "reason": "Repo or Issue not found"}, status=200) + + # Find/Create User Profile + user_profile = UserProfile.objects.filter(github_url=sender.get("html_url")).first() + + # Create Comment + GitHubComment.objects.update_or_create( + comment_id=comment_data.get("id"), + defaults={ + "github_issue": github_issue, + "user_profile": user_profile, + "body": comment_data.get("body", ""), + "comment_type": "review_comment", + "created_at": dateutil_parser.parse(comment_data.get("created_at")), + "updated_at": dateutil_parser.parse(comment_data.get("updated_at")), + "url": comment_data.get("html_url"), + }, + ) + return JsonResponse({"status": "success"}, status=200) + + def assign_github_badge(user, action_title): try: badge, created = Badge.objects.get_or_create(title=action_title, type="automatic")