diff --git a/backend/Makefile b/backend/Makefile index 2b2d68c877..1ce213c1ef 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,5 +1,6 @@ include backend/apps/ai/Makefile include backend/apps/github/Makefile +include backend/apps/mentorship/Makefile include backend/apps/nest/Makefile include backend/apps/owasp/Makefile include backend/apps/slack/Makefile diff --git a/backend/apps/github/admin/__init__.py b/backend/apps/github/admin/__init__.py index df3c242bed..090dbe6a8e 100644 --- a/backend/apps/github/admin/__init__.py +++ b/backend/apps/github/admin/__init__.py @@ -1,5 +1,6 @@ """Github app admin.""" +from .comment import CommentAdmin from .issue import IssueAdmin from .label import LabelAdmin from .milestone import MilestoneAdmin diff --git a/backend/apps/github/admin/comment.py b/backend/apps/github/admin/comment.py new file mode 100644 index 0000000000..c15455acae --- /dev/null +++ b/backend/apps/github/admin/comment.py @@ -0,0 +1,21 @@ +"""GitHub app Comment model admin.""" + +from django.contrib import admin + +from apps.github.models import Comment + + +class CommentAdmin(admin.ModelAdmin): + """Admin for Comment model.""" + + list_display = ( + "body", + "author", + "nest_created_at", + "nest_updated_at", + ) + list_filter = ("nest_created_at", "nest_updated_at") + search_fields = ("body", "author__login") + + +admin.site.register(Comment, CommentAdmin) diff --git a/backend/apps/github/common.py b/backend/apps/github/common.py index 7281963983..a2bfa79097 100644 --- a/backend/apps/github/common.py +++ b/backend/apps/github/common.py @@ -4,10 +4,15 @@ import logging from datetime import timedelta as td +from typing import TYPE_CHECKING from django.utils import timezone from github.GithubException import UnknownObjectException +if TYPE_CHECKING: + from github import Github + +from apps.github.models.comment import Comment from apps.github.models.issue import Issue from apps.github.models.label import Label from apps.github.models.milestone import Milestone @@ -227,3 +232,68 @@ def sync_repository( ) return organization, repository + + +def sync_issue_comments(gh_client: Github, issue: Issue): + """Sync new comments for a mentorship program specific issue on-demand. + + Args: + gh_client (Github): GitHub client. + issue (Issue): The local database Issue object to sync comments for. + + """ + logger.info("Starting comment sync for issue #%s", issue.number) + + try: + if not (repository := issue.repository): + logger.warning("Issue #%s has no repository, skipping", issue.number) + return + + logger.info("Fetching repository: %s", repository.path) + + gh_repository = gh_client.get_repo(repository.path) + gh_issue = gh_repository.get_issue(number=issue.number) + + since = ( + (issue.latest_comment.updated_at or issue.latest_comment.created_at) + if issue.latest_comment + else getattr(issue, "updated_at", None) + ) + + comments = [] + + gh_comments = gh_issue.get_comments(since=since) if since else gh_issue.get_comments() + + for gh_comment in gh_comments: + author = User.update_data(gh_comment.user) + if not author: + logger.warning("Could not sync author for comment %s", gh_comment.id) + continue + + comment = Comment.update_data( + gh_comment, + author=author, + content_object=issue, + save=False, + ) + comments.append(comment) + + if comments: + Comment.bulk_save(comments) + logger.info( + "%d comments synced for issue #%s", + len(comments), + issue.number, + ) + + except UnknownObjectException as e: + logger.warning( + "Could not access issue #%s. Error: %s", + issue.number, + e, + ) + except Exception: + logger.exception( + "An unexpected error occurred during comment sync for issue #%s", + issue.number, + ) diff --git a/backend/apps/github/migrations/0036_comment.py b/backend/apps/github/migrations/0036_comment.py new file mode 100644 index 0000000000..2c67ebd898 --- /dev/null +++ b/backend/apps/github/migrations/0036_comment.py @@ -0,0 +1,61 @@ +# Generated by Django 5.2.5 on 2025-09-24 13:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("github", "0035_alter_user_bio_alter_user_is_owasp_staff"), + ] + + operations = [ + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ("github_id", models.BigIntegerField(unique=True, verbose_name="Github ID")), + ( + "created_at", + models.DateTimeField(blank=True, null=True, verbose_name="Created at"), + ), + ( + "updated_at", + models.DateTimeField( + blank=True, db_index=True, null=True, verbose_name="Updated at" + ), + ), + ("body", models.TextField(verbose_name="Body")), + ("object_id", models.PositiveIntegerField()), + ( + "author", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="comments", + to="github.user", + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype" + ), + ), + ], + options={ + "verbose_name": "Comment", + "verbose_name_plural": "Comments", + "db_table": "github_comments", + "ordering": ("-nest_created_at",), + }, + ), + ] diff --git a/backend/apps/github/models/__init__.py b/backend/apps/github/models/__init__.py index 094a7b9900..c604c01013 100644 --- a/backend/apps/github/models/__init__.py +++ b/backend/apps/github/models/__init__.py @@ -1,5 +1,6 @@ """Github app.""" +from .comment import Comment from .milestone import Milestone from .pull_request import PullRequest from .user import User diff --git a/backend/apps/github/models/comment.py b/backend/apps/github/models/comment.py new file mode 100644 index 0000000000..3543dfd085 --- /dev/null +++ b/backend/apps/github/models/comment.py @@ -0,0 +1,85 @@ +"""GitHub app comment model.""" + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + +from apps.common.models import BulkSaveModel, TimestampedModel +from apps.common.utils import truncate + + +class Comment(BulkSaveModel, TimestampedModel): + """Represents a comment on a GitHub Issue.""" + + class Meta: + db_table = "github_comments" + verbose_name = "Comment" + verbose_name_plural = "Comments" + ordering = ("-nest_created_at",) + + github_id = models.BigIntegerField(unique=True, verbose_name="Github ID") + created_at = models.DateTimeField(verbose_name="Created at", null=True, blank=True) + updated_at = models.DateTimeField( + verbose_name="Updated at", null=True, blank=True, db_index=True + ) + author = models.ForeignKey( + "github.User", on_delete=models.SET_NULL, null=True, related_name="comments" + ) + body = models.TextField(verbose_name="Body") + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + def __str__(self): + """Return a string representation of the comment.""" + return f"{self.author} - {truncate(self.body, 50)}" + + def from_github(self, gh_comment, author=None): + """Populate fields from a GitHub API comment object.""" + field_mapping = { + "body": "body", + "created_at": "created_at", + "updated_at": "updated_at", + } + + for model_field, gh_field in field_mapping.items(): + value = getattr(gh_comment, gh_field, None) + if value is not None: + setattr(self, model_field, value) + + self.author = author + + @staticmethod + def bulk_save(comments, fields=None): # type: ignore[override] + """Bulk save comments.""" + BulkSaveModel.bulk_save(Comment, comments, fields=fields) + + @staticmethod + def update_data(gh_comment, *, author=None, content_object=None, save: bool = True): + """Update or create a Comment instance from a GitHub comment object. + + Args: + gh_comment (github.IssueComment.IssueComment): GitHub comment object. + author (User, optional): Comment author. Defaults to None. + content_object (GenericForeignKey, optional): Content object. Defaults to None. + save (bool, optional): Whether to save the instance immediately. Defaults to True. + + Returns: + Comment: The updated or newly created Comment instance. + + """ + try: + comment = Comment.objects.get(github_id=gh_comment.id) + except Comment.DoesNotExist: + comment = Comment(github_id=gh_comment.id) + + comment.from_github(gh_comment, author=author) + + if content_object is not None: + comment.content_object = content_object + + if save: + comment.save() + + return comment diff --git a/backend/apps/github/models/issue.py b/backend/apps/github/models/issue.py index 693272a017..dccf14588b 100644 --- a/backend/apps/github/models/issue.py +++ b/backend/apps/github/models/issue.py @@ -4,6 +4,7 @@ from functools import lru_cache +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from apps.common.index import IndexBase @@ -54,6 +55,9 @@ class Meta: null=True, related_name="created_issues", ) + + comments = GenericRelation("github.Comment", related_query_name="issue") + milestone = models.ForeignKey( "github.Milestone", on_delete=models.CASCADE, @@ -83,6 +87,16 @@ class Meta: blank=True, ) + @property + def latest_comment(self): + """Get the latest comment for this issue. + + Returns: + Comment | None: The most recently created comment, or None if no comments exist. + + """ + return self.comments.order_by("-nest_created_at").first() + def from_github(self, gh_issue, *, author=None, milestone=None, repository=None): """Update the instance based on GitHub issue data. diff --git a/backend/apps/mentorship/Makefile b/backend/apps/mentorship/Makefile new file mode 100644 index 0000000000..633c6e66d5 --- /dev/null +++ b/backend/apps/mentorship/Makefile @@ -0,0 +1,3 @@ +mentorship-update-comments: + @echo "Syncing Github Comments related to issues" + @CMD="python manage.py mentorship_update_comments" $(MAKE) exec-backend-command diff --git a/backend/apps/mentorship/admin/__init__.py b/backend/apps/mentorship/admin/__init__.py index a5352c1850..9a3c365805 100644 --- a/backend/apps/mentorship/admin/__init__.py +++ b/backend/apps/mentorship/admin/__init__.py @@ -1,5 +1,6 @@ """Mentorship app admin.""" +from .issue_user_interest import IssueUserInterest from .mentee import MenteeAdmin from .mentee_program import MenteeProgramAdmin from .mentor import MentorAdmin diff --git a/backend/apps/mentorship/admin/issue_user_interest.py b/backend/apps/mentorship/admin/issue_user_interest.py new file mode 100644 index 0000000000..d26ed8d8af --- /dev/null +++ b/backend/apps/mentorship/admin/issue_user_interest.py @@ -0,0 +1,16 @@ +"""Mentorship app IssueUserInterest admin.""" + +from django.contrib import admin + +from apps.mentorship.models import IssueUserInterest + + +class IssueUserInterestAdmin(admin.ModelAdmin): + """IssueUserInterest admin.""" + + list_display = ("module", "issue") + search_fields = ("module__name", "user__login", "issue__title") + list_filter = ("module",) + + +admin.site.register(IssueUserInterest, IssueUserInterestAdmin) diff --git a/backend/apps/mentorship/management/__init__.py b/backend/apps/mentorship/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/mentorship/management/commands/__init__.py b/backend/apps/mentorship/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/mentorship/management/commands/mentorship_update_comments.py b/backend/apps/mentorship/management/commands/mentorship_update_comments.py new file mode 100644 index 0000000000..229a817961 --- /dev/null +++ b/backend/apps/mentorship/management/commands/mentorship_update_comments.py @@ -0,0 +1,152 @@ +"""Sync comments for issues relevant to published mentorship modules.""" + +import logging +import re +from typing import Any + +from django.core.management.base import BaseCommand + +from apps.common.utils import truncate +from apps.github.auth import get_github_client +from apps.github.common import sync_issue_comments +from apps.github.models.issue import Issue +from apps.mentorship.models import IssueUserInterest, Module + +logger: logging.Logger = logging.getLogger(__name__) + +INTEREST_PATTERNS = [ + re.compile(r"/interested", re.IGNORECASE), +] + + +class Command(BaseCommand): + """Sync comments for issues relevant to active mentorship modules and process interests.""" + + help = "Sync comments for issues relevant to active mentorship modules and process interests" + + def handle(self, *args, **options) -> None: + """Handle the command execution.""" + self.process_mentorship_modules() + + def process_mentorship_modules(self) -> None: + """Process all active mentorship modules.""" + published_modules = Module.published_modules.all() + + if not published_modules.exists(): + self.stdout.write( + self.style.WARNING("No published mentorship modules found. Exiting.") + ) + return + + self.stdout.write(self.style.SUCCESS("Starting mentorship issue processing job...")) + + for module in published_modules: + self.stdout.write(f"\nProcessing module: {module.name}...") + self.process_module(module) + + self.stdout.write(self.style.SUCCESS("Processed successfully!")) + + def process_module(self, module: Module) -> None: + """Process a single mentorship module. + + Args: + module (Module): The module instance to process. + + """ + gh = get_github_client() + + module_repos = ( + module.project.repositories.filter(id__isnull=False) + .values_list("id", flat=True) + .distinct() + ) + + if not module_repos.exists(): + self.stdout.write( + self.style.WARNING(f"Skipping. Module '{module.name}' has no repositories.") + ) + return + + relevant_issues = Issue.objects.filter( + repository_id__in=module_repos, state=Issue.State.OPEN + ).distinct() + + self.stdout.write(f"Found {relevant_issues.count()} open issues across repositories") + + for issue in relevant_issues: + self.stdout.write( + f"Syncing comments for issue #{issue.number} '{truncate(issue.title, 20)}'" + ) + sync_issue_comments(gh, issue) + self.process_issue_interests(issue, module) + + def process_issue_interests(self, issue: Issue, module: Module) -> None: + """Process interests for a single issue. + + Args: + issue (Issue): The issue instance to process. + module (Module): The module instance. + + """ + existing_interests = IssueUserInterest.objects.filter(module=module, issue=issue) + existing_user_ids = set(existing_interests.values_list("user_id", flat=True)) + + all_comments = ( + issue.comments.select_related("author") + .filter(author__isnull=False) + .order_by("author_id", "nest_created_at") + ) + + interests_to_create = [] + interests_to_remove = [] + new_user_logins = [] + removed_user_logins = [] + + user_interest_status: dict[int, dict[str, Any]] = {} + + for comment in all_comments: + user_id = comment.author.id + entry = user_interest_status.get(user_id) + is_match = any(p.search(comment.body or "") for p in INTEREST_PATTERNS) + if entry: + entry["is_interested"] = entry["is_interested"] or is_match + else: + user_interest_status[user_id] = { + "is_interested": is_match, + "login": comment.author.login, + "author": comment.author, + } + + for user_id, status in user_interest_status.items(): + is_interested = status["is_interested"] + user_login = status["login"] + author = status["author"] + + if is_interested and user_id not in existing_user_ids: + interests_to_create.append( + IssueUserInterest(module=module, issue=issue, user=author) + ) + new_user_logins.append(user_login) + elif not is_interested and user_id in existing_user_ids: + interests_to_remove.append(user_id) + removed_user_logins.append(user_login) + + if interests_to_create: + IssueUserInterest.objects.bulk_create(interests_to_create) + self.stdout.write( + self.style.SUCCESS( + f"Registered {len(interests_to_create)} new interest(s) " + f"for issue #{issue.number}: {', '.join(new_user_logins)}" + ) + ) + + if interests_to_remove: + removed_count = IssueUserInterest.objects.filter( + module=module, issue=issue, user_id__in=interests_to_remove + ).delete()[0] + self.stdout.write( + self.style.WARNING( + f"Unregistered {removed_count} interest(s) " + f"for issue #{issue.number}: {', '.join(removed_user_logins)}" + ) + ) diff --git a/backend/apps/mentorship/migrations/0005_issueuserinterest.py b/backend/apps/mentorship/migrations/0005_issueuserinterest.py new file mode 100644 index 0000000000..d054f6c5e5 --- /dev/null +++ b/backend/apps/mentorship/migrations/0005_issueuserinterest.py @@ -0,0 +1,55 @@ +# Generated by Django 5.2.5 on 2025-09-24 13:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0036_comment"), + ("mentorship", "0004_module_key_program_key_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="IssueUserInterest", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="participant_interests", + to="github.issue", + ), + ), + ( + "module", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="interests", + to="mentorship.module", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="mentorship_interests", + to="github.user", + ), + ), + ], + options={ + "verbose_name": "Issue User Interest", + "verbose_name_plural": "Issue User Interests", + "db_table": "mentorship_issue_user_interests", + "unique_together": {("module", "issue", "user")}, + }, + ), + ] diff --git a/backend/apps/mentorship/models/__init__.py b/backend/apps/mentorship/models/__init__.py index d8e5753569..f196ad9a3e 100644 --- a/backend/apps/mentorship/models/__init__.py +++ b/backend/apps/mentorship/models/__init__.py @@ -1,3 +1,4 @@ +from .issue_user_interest import IssueUserInterest from .mentee import Mentee from .mentee_module import MenteeModule from .mentee_program import MenteeProgram diff --git a/backend/apps/mentorship/models/issue_user_interest.py b/backend/apps/mentorship/models/issue_user_interest.py new file mode 100644 index 0000000000..b59128bc85 --- /dev/null +++ b/backend/apps/mentorship/models/issue_user_interest.py @@ -0,0 +1,31 @@ +"""Participant interest model.""" + +from django.db import models + + +class IssueUserInterest(models.Model): + """Represents users interested in a specific issue within a module.""" + + class Meta: + db_table = "mentorship_issue_user_interests" + verbose_name = "Issue User Interest" + verbose_name_plural = "Issue User Interests" + unique_together = ("module", "issue", "user") + + module = models.ForeignKey( + "mentorship.Module", on_delete=models.CASCADE, related_name="interests" + ) + issue = models.ForeignKey( + "github.Issue", on_delete=models.CASCADE, related_name="participant_interests" + ) + user = models.ForeignKey( + "github.User", + related_name="mentorship_interests", + on_delete=models.CASCADE, + ) + + def __str__(self): + """Return a human-readable representation of the issue user interest.""" + return ( + f"User [{self.user.login}] interested in '{self.issue.title}' for {self.module.name}" + ) diff --git a/backend/apps/mentorship/models/managers/__init__.py b/backend/apps/mentorship/models/managers/__init__.py new file mode 100644 index 0000000000..b90a81f022 --- /dev/null +++ b/backend/apps/mentorship/models/managers/__init__.py @@ -0,0 +1 @@ +from .module import PublishedModuleManager diff --git a/backend/apps/mentorship/models/managers/module.py b/backend/apps/mentorship/models/managers/module.py new file mode 100644 index 0000000000..1a203db9a1 --- /dev/null +++ b/backend/apps/mentorship/models/managers/module.py @@ -0,0 +1,13 @@ +"""Mentorship app module manager.""" + +from django.db import models + +from apps.mentorship.models.program import Program + + +class PublishedModuleManager(models.Manager): + """Published Modules.""" + + def get_queryset(self): + """Get queryset.""" + return super().get_queryset().filter(program__status=Program.ProgramStatus.PUBLISHED) diff --git a/backend/apps/mentorship/models/module.py b/backend/apps/mentorship/models/module.py index 7309baca73..9cacd029ae 100644 --- a/backend/apps/mentorship/models/module.py +++ b/backend/apps/mentorship/models/module.py @@ -11,11 +11,15 @@ MatchingAttributes, StartEndRange, ) +from apps.mentorship.models.managers import PublishedModuleManager class Module(ExperienceLevel, MatchingAttributes, StartEndRange, TimestampedModel): """Module model representing a program unit.""" + objects = models.Manager() + published_modules = PublishedModuleManager() + class Meta: db_table = "mentorship_modules" verbose_name_plural = "Modules"