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/Makefile b/backend/apps/github/Makefile index 018876262e..e0ea5e7def 100644 --- a/backend/apps/github/Makefile +++ b/backend/apps/github/Makefile @@ -17,3 +17,7 @@ github-update-related-organizations: github-update-users: @echo "Updating GitHub users" @CMD="python manage.py github_update_users" $(MAKE) exec-backend-command + +github-update-pull-requests: + @echo "Linking pull requests to issues using closing keywords" + @CMD="python manage.py github_update_pull_requests" $(MAKE) exec-backend-command 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/admin/issue.py b/backend/apps/github/admin/issue.py index 787781ffec..3c397dcd85 100644 --- a/backend/apps/github/admin/issue.py +++ b/backend/apps/github/admin/issue.py @@ -19,6 +19,7 @@ class IssueAdmin(admin.ModelAdmin): "repository", "created_at", "title", + "level", "custom_field_github_url", ) list_filter = ( diff --git a/backend/apps/github/admin/pull_request.py b/backend/apps/github/admin/pull_request.py index 99a8194951..c177b4c987 100644 --- a/backend/apps/github/admin/pull_request.py +++ b/backend/apps/github/admin/pull_request.py @@ -14,6 +14,7 @@ class PullRequestAdmin(admin.ModelAdmin): "author", "labels", "repository", + "related_issues", ) list_display = ( "repository", diff --git a/backend/apps/github/api/internal/nodes/issue.py b/backend/apps/github/api/internal/nodes/issue.py index 4ac7eb7672..1b4d89d232 100644 --- a/backend/apps/github/api/internal/nodes/issue.py +++ b/backend/apps/github/api/internal/nodes/issue.py @@ -3,15 +3,19 @@ import strawberry import strawberry_django +from apps.github.api.internal.nodes.pull_request import PullRequestNode from apps.github.api.internal.nodes.user import UserNode from apps.github.models.issue import Issue +from apps.mentorship.models import IssueUserInterest # add import @strawberry_django.type( Issue, fields=[ "created_at", + "number", "state", + "summary", "title", "url", ], @@ -37,3 +41,23 @@ def organization_name(self) -> str | None: def repository_name(self) -> str | None: """Resolve the repository name.""" return self.repository.name if self.repository else None + + @strawberry.field + def assignees(self) -> list[UserNode]: + """Resolve assignees list.""" + return list(self.assignees.all()) + + @strawberry.field + def labels(self) -> list[str]: + """Resolve label names for the issue.""" + return list(self.labels.values_list("name", flat=True)) + + @strawberry.field + def interested_users(self) -> list[UserNode]: + """Return all users who have expressed interest in this issue.""" + return [interest.user for interest in IssueUserInterest.objects.filter(issue=self)] + + @strawberry.field + def pull_requests(self) -> list[PullRequestNode]: + """Return all pull requests linked to this issue.""" + return list(self.pull_requests.select_related("author", "repository").all()) diff --git a/backend/apps/github/common.py b/backend/apps/github/common.py index 7281963983..ae0906eda6 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,61 @@ 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.comments.order_by("-updated_at").values_list( + "updated_at", flat=True + ).first() or 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, save=False) + comment.content_object = issue + comments.append(comment) + + Comment.bulk_save(comments) + logger.info( + "%d comments 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/management/commands/github_update_pull_requests.py b/backend/apps/github/management/commands/github_update_pull_requests.py new file mode 100644 index 0000000000..d41593b288 --- /dev/null +++ b/backend/apps/github/management/commands/github_update_pull_requests.py @@ -0,0 +1,60 @@ +"""Link pull requests to issues via closing keywords in PR body (e.g., 'closes #123').""" + +import logging +import re + +from django.core.management.base import BaseCommand + +from apps.github.models.issue import Issue +from apps.github.models.pull_request import PullRequest + +logger: logging.Logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Link pull requests to issues via closing keywords in PR body (e.g., 'closes #123')." + + # regex pattern to find the linked issue + pattern = re.compile( + r"\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\b\s+" + r"#(\d+)", + re.IGNORECASE, + ) + + def handle(self, *args, **options): + linked = 0 + updated_prs = [] + + logger.info("Linking PRs to issues using closing keywords") + + queryset = PullRequest.objects.select_related("repository").all() + + for pr in queryset: + if not pr.repository: + logger.info("Skipping PR #%s: no repository", pr.number) + continue + + body = pr.body or "" + matches = self.pattern.findall(body) + if not matches: + logger.info("No closing keyword pattern found for PR #%s", pr.number) + continue + issue_numbers = {int(n) for n in matches} + + issues = list(Issue.objects.filter(repository=pr.repository, number__in=issue_numbers)) + + existing_ids = set(pr.related_issues.values_list("id", flat=True)) + new_ids = {i.id for i in issues} - existing_ids + if new_ids: + pr.related_issues.add(*new_ids) + linked += len(new_ids) + updated_prs.append(pr) + self.stdout.write( + f"Linked PR #{pr.number} ({pr.repository.name}) -> Issues " + + ", ".join(f"#{i.number}" for i in issues if i.id in new_ids) + ) + + if updated_prs: + PullRequest.bulk_save(updated_prs) + + self.stdout.write(f"Linked: {linked}") diff --git a/backend/apps/github/migrations/0037_issue_level_comment.py b/backend/apps/github/migrations/0037_issue_level_comment.py new file mode 100644 index 0000000000..ef9045160f --- /dev/null +++ b/backend/apps/github/migrations/0037_issue_level_comment.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.5 on 2025-09-30 10:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("github", "0036_user_has_public_member_page_alter_organization_name_and_more"), + ("mentorship", "0004_module_key_program_key_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="level", + field=models.ForeignKey( + blank=True, + help_text="The difficulty level of this issue.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="issues", + to="mentorship.tasklevel", + ), + ), + 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/migrations/0038_pullrequest_related_issues.py b/backend/apps/github/migrations/0038_pullrequest_related_issues.py new file mode 100644 index 0000000000..af5a5bd7f4 --- /dev/null +++ b/backend/apps/github/migrations/0038_pullrequest_related_issues.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.5 on 2025-10-06 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0037_issue_level_comment"), + ] + + operations = [ + migrations.AddField( + model_name="pullrequest", + name="related_issues", + field=models.ManyToManyField( + blank=True, related_name="pull_requests", to="github.issue", verbose_name="Issues" + ), + ), + ] diff --git a/backend/apps/github/models/__init__.py b/backend/apps/github/models/__init__.py index 094a7b9900..1b17164691 100644 --- a/backend/apps/github/models/__init__.py +++ b/backend/apps/github/models/__init__.py @@ -1,5 +1,7 @@ """Github app.""" +from .comment import Comment +from .issue import Issue 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..aefc5384d7 --- /dev/null +++ b/backend/apps/github/models/comment.py @@ -0,0 +1,81 @@ +"""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, 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. + 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 save: + comment.save() + + return comment diff --git a/backend/apps/github/models/issue.py b/backend/apps/github/models/issue.py index 693272a017..c643a451aa 100644 --- a/backend/apps/github/models/issue.py +++ b/backend/apps/github/models/issue.py @@ -4,16 +4,16 @@ from functools import lru_cache +from django.contrib.contenttypes.fields import GenericRelation from django.db import models from apps.common.index import IndexBase from apps.common.models import BulkSaveModel from apps.common.open_ai import OpenAi from apps.core.models.prompt import Prompt +from apps.github.models.generic_issue_model import GenericIssueModel from apps.github.models.managers.issue import OpenIssueManager -from .generic_issue_model import GenericIssueModel - class Issue(GenericIssueModel): """Issue model.""" @@ -54,6 +54,17 @@ class Meta: null=True, related_name="created_issues", ) + level = models.ForeignKey( + "mentorship.TaskLevel", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="issues", + help_text="The difficulty level of this issue.", + ) + + comments = GenericRelation("github.Comment", related_query_name="issue") + milestone = models.ForeignKey( "github.Milestone", on_delete=models.CASCADE, @@ -83,6 +94,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/github/models/pull_request.py b/backend/apps/github/models/pull_request.py index 84e1deac9e..e3677880d9 100644 --- a/backend/apps/github/models/pull_request.py +++ b/backend/apps/github/models/pull_request.py @@ -61,6 +61,12 @@ class Meta: related_name="pull_request_labels", blank=True, ) + related_issues = models.ManyToManyField( + "github.Issue", + verbose_name="Issues", + related_name="pull_requests", + blank=True, + ) def from_github(self, gh_pull_request, *, author=None, milestone=None, repository=None): """Update the instance based on GitHub pull request data. diff --git a/backend/apps/mentorship/Makefile b/backend/apps/mentorship/Makefile new file mode 100644 index 0000000000..a12d49d5d7 --- /dev/null +++ b/backend/apps/mentorship/Makefile @@ -0,0 +1,9 @@ +mentorship-sync-module-issues: + @CMD="python manage.py sync_module_issues" $(MAKE) exec-backend-command + +mentorship-sync-issue-levels: + @CMD="python manage.py sync_issue_levels" $(MAKE) exec-backend-command + +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/admin/module.py b/backend/apps/mentorship/admin/module.py index 1eb248c4dc..df25f85a93 100644 --- a/backend/apps/mentorship/admin/module.py +++ b/backend/apps/mentorship/admin/module.py @@ -13,6 +13,7 @@ class ModuleAdmin(admin.ModelAdmin): "program", "project", ) + autocomplete_fields = ("issues",) search_fields = ( "name", diff --git a/backend/apps/mentorship/admin/task.py b/backend/apps/mentorship/admin/task.py index 74662c10f1..e5ba082a8f 100644 --- a/backend/apps/mentorship/admin/task.py +++ b/backend/apps/mentorship/admin/task.py @@ -25,5 +25,7 @@ class TaskAdmin(admin.ModelAdmin): list_filter = ("status", "module") + ordering = ["-assigned_at"] + admin.site.register(Task, TaskAdmin) diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py index b185f10148..ed81af8d02 100644 --- a/backend/apps/mentorship/api/internal/mutations/module.py +++ b/backend/apps/mentorship/api/internal/mutations/module.py @@ -14,6 +14,7 @@ UpdateModuleInput, ) from apps.mentorship.models import Mentor, Module, Program +from apps.mentorship.models.issue_user_interest import IssueUserInterest from apps.nest.api.internal.permissions import IsAuthenticated from apps.owasp.models import Project @@ -114,6 +115,88 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) -> return module + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def assign_issue_to_user( + self, + info: strawberry.Info, + *, + module_key: str, + program_key: str, + issue_number: int, + user_login: str, + ) -> ModuleNode: + """Assign an issue to a user by updating Issue.assignees within the module scope.""" + user = info.context.request.user + + module = ( + Module.objects.select_related("program") + .filter(key=module_key, program__key=program_key) + .first() + ) + if module is None: + raise ObjectDoesNotExist(msg="Module not found.") + + mentor = Mentor.objects.filter(nest_user=user).first() + if mentor is None: + raise PermissionDenied(msg="Only mentors can assign issues.") + if not module.program.admins.filter(id=mentor.id).exists(): + raise PermissionDenied + + gh_user = GithubUser.objects.filter(login=user_login).first() + if gh_user is None: + raise ObjectDoesNotExist(msg="Assignee not found.") + + issue = module.issues.filter(number=issue_number).first() + if issue is None: + raise ObjectDoesNotExist(msg="Issue not found in this module.") + + issue.assignees.add(gh_user) + + IssueUserInterest.objects.filter(module=module, issue_id=issue.id, user=gh_user).delete() + + return module + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def unassign_issue_from_user( + self, + info: strawberry.Info, + *, + module_key: str, + program_key: str, + issue_number: int, + user_login: str, + ) -> ModuleNode: + """Unassign an issue from a user by updating Issue.assignees within the module scope.""" + user = info.context.request.user + + module = ( + Module.objects.select_related("program") + .filter(key=module_key, program__key=program_key) + .first() + ) + if module is None: + raise ObjectDoesNotExist + + mentor = Mentor.objects.filter(nest_user=user).first() + if mentor is None: + raise PermissionDenied + if not module.program.admins.filter(id=mentor.id).exists(): + raise PermissionDenied + + gh_user = GithubUser.objects.filter(login=user_login).first() + if gh_user is None: + raise ObjectDoesNotExist(msg="Assignee not found.") + + issue = module.issues.filter(number=issue_number).first() + if issue is None: + raise ObjectDoesNotExist(msg=f"Issue {issue_number} not found in this module.") + + issue.assignees.remove(gh_user) + + return module + @strawberry.mutation(permission_classes=[IsAuthenticated]) @transaction.atomic def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> ModuleNode: @@ -125,19 +208,17 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> key=input_data.key, program__key=input_data.program_key ) except Module.DoesNotExist as e: - msg = "Module not found." - raise ObjectDoesNotExist(msg) from e + raise ObjectDoesNotExist(msg="Module not found.") from e try: creator_as_mentor = Mentor.objects.get(nest_user=user) except Mentor.DoesNotExist as err: - msg = "Only mentors can edit modules." logger.warning( "User '%s' is not a mentor and cannot edit modules.", user.username, exc_info=True, ) - raise PermissionDenied(msg) from err + raise PermissionDenied(msg="Only mentors can edit modules.") from err if not module.program.admins.filter(id=creator_as_mentor.id).exists(): raise PermissionDenied diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py index fa94ad4728..17ad8f82ba 100644 --- a/backend/apps/mentorship/api/internal/nodes/module.py +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -4,9 +4,13 @@ import strawberry +from apps.github.api.internal.nodes.issue import IssueNode +from apps.github.api.internal.nodes.user import UserNode from apps.mentorship.api.internal.nodes.enum import ExperienceLevelEnum from apps.mentorship.api.internal.nodes.mentor import MentorNode from apps.mentorship.api.internal.nodes.program import ProgramNode +from apps.mentorship.models.issue_user_interest import IssueUserInterest +from apps.mentorship.models.task import Task @strawberry.type @@ -35,6 +39,60 @@ def project_name(self) -> str | None: """Get the project name for this module.""" return self.project.name if self.project else None + @strawberry.field + def issues(self) -> list[IssueNode]: + """Return issues linked to this module.""" + return list( + self.issues.select_related("repository", "author") + .prefetch_related("assignees", "labels") + .order_by("-created_at") + ) + + @strawberry.field + def issue_by_number(self, number: int) -> IssueNode | None: + """Return a single issue by its GitHub number within this module's linked issues.""" + return ( + self.issues.select_related("repository", "author") + .prefetch_related("assignees", "labels") + .filter(number=number) + .first() + ) + + @strawberry.field + def interested_users(self, issue_number: int) -> list[UserNode]: + """Return users interested in this module's issue identified by its number.""" + issue_ids = list(self.issues.filter(number=issue_number).values_list("id", flat=True)) + if not issue_ids: + return [] + interests = ( + IssueUserInterest.objects.select_related("user") + .filter(module=self, issue_id__in=issue_ids) + .order_by("user__login") + ) + return [i.user for i in interests] + + @strawberry.field + def task_deadline(self, issue_number: int) -> datetime | None: + """Return the earliest deadline for tasks linked to this module and issue number.""" + return ( + Task.objects.filter(issue__number=issue_number) + .order_by("deadline_at") + .values_list("deadline_at", flat=True) + .first() + ) + + @strawberry.field + def task_assigned_at(self, issue_number: int) -> datetime | None: + """Return the earliest assignment time for tasks linked to this module and issue number.""" + return ( + Task.objects.filter( + issue__number=issue_number, + ) + .order_by("assigned_at") + .values_list("assigned_at", flat=True) + .first() + ) + @strawberry.input class CreateModuleInput: 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..52e796841b --- /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").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: + if not comment.author: + continue + 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/management/commands/sync_issue_levels.py b/backend/apps/mentorship/management/commands/sync_issue_levels.py new file mode 100644 index 0000000000..a1abc1da38 --- /dev/null +++ b/backend/apps/mentorship/management/commands/sync_issue_levels.py @@ -0,0 +1,97 @@ +"""A command to sync issue level with Tasklevel.""" + +from django.core.management.base import BaseCommand +from django.db.models import Prefetch + +from apps.github.models.issue import Issue +from apps.github.models.label import Label +from apps.mentorship.models.task_level import TaskLevel +from apps.mentorship.utils import normalize_name + + +class Command(BaseCommand): + """Syncs the `level` field on Issues based on matching labels, respecting Module constraints. + + If any label matches a TaskLevel in the Issue's Module, that TaskLevel is assigned. + """ + + help = "Assigns a TaskLevel to each Issue by matching labels within the same Module." + + def _build_module_level_maps(self, all_levels): + """Build a mapping from module ID to a dictionary of data. + + The dictionary contains a 'label_to_level_map' for normalized label/level + names to TaskLevel objects. + """ + module_data_map = {} + for level in all_levels: + module_id = level.module_id + level_map_container = module_data_map.setdefault(module_id, {"label_to_level_map": {}}) + level_map = level_map_container["label_to_level_map"] + + normalized_level_name = normalize_name(level.name) + level_map[normalized_level_name] = level + + for label_name in level.labels: + normalized_label = normalize_name(label_name) + level_map[normalized_label] = level + return module_data_map + + def _find_best_match_level( + self, + issue_labels_normalized, + issue_mentorship_modules, + module_data_map, + ): + """Find the best matching TaskLevel for an issue based on its labels and modules.""" + for module in issue_mentorship_modules: + if module.id in module_data_map: + module_level_map = module_data_map[module.id]["label_to_level_map"] + for label_name in issue_labels_normalized: + if label_name in module_level_map: + return module_level_map[label_name] + return None + + def handle(self, *args, **options): + self.stdout.write("Starting...") + + # 1. Build a per-module map (normalized label → TaskLevel) + all_levels = TaskLevel.objects.select_related("module").order_by("name") + + if not all_levels.exists(): + self.stdout.write( + self.style.WARNING("No TaskLevel objects found in the database. Exiting.") + ) + return + + module_data_map = self._build_module_level_maps(all_levels) + self.stdout.write(f"Built label maps for {len(module_data_map)} modules.") + + # 2.match issue labels to TaskLevels + issues_to_update = [] + issues_query = Issue.objects.prefetch_related( + Prefetch("labels", queryset=Label.objects.only("name")), + "mentorship_modules", + ).select_related("level") + + for issue in issues_query: + issue_labels_normalized = {normalize_name(label.name) for label in issue.labels.all()} + + best_match_level = self._find_best_match_level( + issue_labels_normalized, + list(issue.mentorship_modules.all()), + module_data_map, + ) + + if issue.level != best_match_level: + issue.level = best_match_level + issues_to_update.append(issue) + + if issues_to_update: + updated_count = len(issues_to_update) + Issue.objects.bulk_update(issues_to_update, ["level"]) + self.stdout.write( + self.style.SUCCESS(f"Successfully updated the level for {updated_count} issues.") + ) + else: + self.stdout.write(self.style.SUCCESS("All issue levels are already up-to-date.")) diff --git a/backend/apps/mentorship/management/commands/sync_module_issues.py b/backend/apps/mentorship/management/commands/sync_module_issues.py new file mode 100644 index 0000000000..9a19fd6b6f --- /dev/null +++ b/backend/apps/mentorship/management/commands/sync_module_issues.py @@ -0,0 +1,237 @@ +"""A command to sync update relation between module and issue and create task.""" + +from urllib.parse import urlparse + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone +from github.GithubException import GithubException + +from apps.github.auth import get_github_client +from apps.github.models.issue import Issue +from apps.mentorship.models.module import Module +from apps.mentorship.models.task import Task +from apps.mentorship.utils import normalize_name + + +class Command(BaseCommand): + """Efficiently syncs issues to mentorship modules based on matching labels.""" + + help = ( + "Syncs issues to modules by matching labels from all repositories " + "associated with the module's project and creates related tasks." + ) + ALLOWED_GITHUB_HOSTS = {"github.com", "www.github.com"} + REPO_PATH_PARTS = 2 + + def _extract_repo_full_name(self, repository): + """Extract repository full name from Repository model or URL string.""" + if hasattr(repository, "path"): + return repository.path + + repo_url = str(repository) if repository else "" + parsed = urlparse(repo_url) + if parsed.netloc in self.ALLOWED_GITHUB_HOSTS: + parts = parsed.path.strip("/").split("/") + if len(parts) >= self.REPO_PATH_PARTS: + return "/".join(parts[: self.REPO_PATH_PARTS]) + return None + return None + + def _get_status(self, issue, assignee): + """Map GitHub issue state + assignment to task status.""" + if issue.state.lower() == "closed": + return Task.Status.COMPLETED + + if assignee: + return Task.Status.IN_PROGRESS + + return Task.Status.TODO + + def _get_last_assigned_date(self, repo, issue_number, assignee_login): + """Find the most recent 'assigned' event for a specific user using PyGithub.""" + try: + gh_issue = repo.get_issue(number=issue_number) + last_dt = None + for event in gh_issue.get_events(): + if ( + event.event == "assigned" + and event.assignee + and event.assignee.login == assignee_login + ): + last_dt = event.created_at + + if last_dt and timezone.is_naive(last_dt): + return timezone.make_aware(last_dt, timezone.utc) + return last_dt # noqa: TRY300 + + except GithubException as e: + self.stderr.write( + self.style.ERROR(f"Unexpected error for {repo.name}#{issue_number}: {e}") + ) + + return None + + def _build_repo_label_to_issue_map(self): + """Build a map from (repository_id, normalized_label_name) to a set of issue IDs.""" + self.stdout.write("Building a repository-aware map of labels to issues...") + repo_label_to_issue_ids = {} + rows = ( + Issue.objects.filter(labels__isnull=False, repository__isnull=False) + .values_list("id", "repository_id", "labels__name") + .iterator(chunk_size=5000) + ) + for issue_id, repo_id, label_name in rows: + key = (repo_id, normalize_name(label_name)) + repo_label_to_issue_ids.setdefault(key, set()).add(issue_id) + + self.stdout.write( + f"Map built. Found issues for {len(repo_label_to_issue_ids)} unique repo-label pairs." + ) + return repo_label_to_issue_ids + + def _process_module( + self, + module, + repo_label_to_issue_ids, + gh_client, + repo_cache, + verbosity, + ): + """Process a single module to link issues and create tasks.""" + project_repos = list(module.project.repositories.all()) + linked_label_names = module.labels + num_tasks_created = 0 + + matched_issue_ids = set() + for repo in project_repos: + for label_name in linked_label_names: + normalized_label = normalize_name(label_name) + key = (repo.id, normalized_label) + issues_for_label = repo_label_to_issue_ids.get(key, set()) + matched_issue_ids.update(issues_for_label) + + with transaction.atomic(): + module.issues.set(matched_issue_ids) + + if matched_issue_ids: + issues = ( + Issue.objects.filter( + id__in=matched_issue_ids, + assignees__isnull=False, + ) + .select_related("repository") + .prefetch_related("assignees", "labels") + .distinct() + ) + + for issue in issues: + assignee = issue.assignees.first() + if not assignee: + continue + + status = self._get_status(issue, assignee) + task, created = Task.objects.get_or_create( + issue=issue, + assignee=assignee, + defaults={"module": module, "status": status}, + ) + + updates = {} + if task.module != module: + updates["module"] = module + if task.status != status: + updates["status"] = status + + # Only fetch assigned_at when needed. + if (created or task.assigned_at is None) and issue.repository: + repo_full_name = self._extract_repo_full_name(issue.repository) + if repo_full_name: + if repo_full_name not in repo_cache: + try: + repo_cache[repo_full_name] = gh_client.get_repo(repo_full_name) + except GithubException as e: + self.stderr.write( + self.style.ERROR( + f"Failed to fetch repo '{repo_full_name}': {e}" + ) + ) + repo_cache[repo_full_name] = None + repo = repo_cache.get(repo_full_name) + if repo: + assigned_date = self._get_last_assigned_date( + repo=repo, + issue_number=issue.number, + assignee_login=assignee.login, + ) + if assigned_date: + updates["assigned_at"] = assigned_date + + if created: + num_tasks_created += 1 + self.stdout.write( + self.style.SUCCESS( + f"Task created for user '{assignee.login}' on issue " + f"{issue.repository.name}#{issue.number} " + f"in module '{module.name}'" + ) + ) + + if updates: + for field, value in updates.items(): + setattr(task, field, value) + task.save(update_fields=list(updates.keys())) + + num_linked = len(matched_issue_ids) + if num_linked > 0: + repo_names = ", ".join([r.name for r in project_repos]) + log_message = ( + f"Updated module '{module.name}': set {num_linked} issues from " + f"repos: [{repo_names}]" + ) + if num_tasks_created > 0: + log_message += f" and created {num_tasks_created} tasks." + + self.stdout.write(self.style.SUCCESS(log_message)) + + if verbosity > 1 and num_tasks_created > 0: + self.stdout.write(self.style.SUCCESS(f" - Created {num_tasks_created} tasks.")) + return num_linked + + def handle(self, *_args, **options): + self.stdout.write("starting...") + verbosity = options["verbosity"] + gh_client = get_github_client() + repo_cache = {} + + repo_label_to_issue_ids = self._build_repo_label_to_issue_map() + + total_links_created = 0 + total_modules_updated = 0 + + self.stdout.write("Processing modules and linking issues...") + modules_to_process = ( + Module.objects.prefetch_related("project__repositories") + .exclude(project__repositories__isnull=True) + .exclude(labels__isnull=True) + .exclude(labels=[]) + ) + + for module in modules_to_process: + links_created = self._process_module( + module=module, + repo_label_to_issue_ids=repo_label_to_issue_ids, + gh_client=gh_client, + repo_cache=repo_cache, + verbosity=verbosity, + ) + if links_created > 0: + total_links_created += links_created + total_modules_updated += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Completed. {total_links_created} issue links set " + f"across {total_modules_updated} modules." + ) + ) diff --git a/backend/apps/mentorship/migrations/0005_remove_task_level_module_issues_and_more.py b/backend/apps/mentorship/migrations/0005_remove_task_level_module_issues_and_more.py new file mode 100644 index 0000000000..3f3f93e1e9 --- /dev/null +++ b/backend/apps/mentorship/migrations/0005_remove_task_level_module_issues_and_more.py @@ -0,0 +1,79 @@ +# Generated by Django 5.2.5 on 2025-09-30 10:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0037_issue_level_comment"), + ("mentorship", "0004_module_key_program_key_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="task", + name="level", + ), + migrations.AddField( + model_name="module", + name="issues", + field=models.ManyToManyField( + blank=True, + help_text="Issues linked to this module via label matching.", + related_name="mentorship_modules", + to="github.issue", + verbose_name="Linked Issues", + ), + ), + migrations.AlterField( + model_name="task", + name="assigned_at", + field=models.DateTimeField( + blank=True, + help_text="Timestamp when the task was assigned to the mentee.", + null=True, + ), + ), + 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..6c5263feeb 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" @@ -65,6 +69,14 @@ class Meta: ) # M2Ms. + issues = models.ManyToManyField( + "github.Issue", + verbose_name="Linked Issues", + related_name="mentorship_modules", + blank=True, + help_text="Issues linked to this module via label matching.", + ) + mentors = models.ManyToManyField( "mentorship.Mentor", verbose_name="Mentors", diff --git a/backend/apps/mentorship/models/task.py b/backend/apps/mentorship/models/task.py index a572b76bde..4160865310 100644 --- a/backend/apps/mentorship/models/task.py +++ b/backend/apps/mentorship/models/task.py @@ -25,7 +25,8 @@ class Status(models.TextChoices): COMPLETED = "COMPLETED", "Completed" assigned_at = models.DateTimeField( - auto_now_add=True, + null=True, + blank=True, help_text="Timestamp when the task was assigned to the mentee.", ) @@ -63,15 +64,6 @@ class Status(models.TextChoices): help_text="The GitHub issue this task corresponds to.", ) - level = models.ForeignKey( - "mentorship.TaskLevel", - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name="tasks", - help_text="The difficulty level of this task.", - ) - module = models.ForeignKey( "mentorship.Module", on_delete=models.CASCADE, diff --git a/backend/apps/mentorship/utils.py b/backend/apps/mentorship/utils.py new file mode 100644 index 0000000000..8432cb97c2 --- /dev/null +++ b/backend/apps/mentorship/utils.py @@ -0,0 +1,6 @@ +"""Utility functions for the mentorship app.""" + + +def normalize_name(name): + """Normalize a string by stripping whitespace and converting to lowercase.""" + return (name or "").strip().casefold() diff --git a/backend/manage.py b/backend/manage.py index 83d4673775..b093d1c8f4 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -5,10 +5,13 @@ import os import sys +from dotenv import load_dotenv + +load_dotenv() if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.local") os.environ.setdefault("DJANGO_CONFIGURATION", "Local") - + load_dotenv() from configurations.management import execute_from_command_line execute_from_command_line(sys.argv) diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page.tsx new file mode 100644 index 0000000000..cc668e6c68 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page.tsx @@ -0,0 +1,297 @@ +'use client' + +import { useMutation, useQuery } from '@apollo/client' +import { + faCodeBranch, + faLink, + faPlus, + faTags, + faUsers, + faXmark, +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import Image from 'next/image' +import Link from 'next/link' +import { useParams } from 'next/navigation' +import { ErrorDisplay } from 'app/global-error' +import { + ASSIGN_ISSUE_TO_USER, + GET_MODULE_ISSUE_VIEW, + UNASSIGN_ISSUE_FROM_USER, +} from 'server/queries/issueQueries' +import ActionButton from 'components/ActionButton' +import AnchorTitle from 'components/AnchorTitle' +import LoadingSpinner from 'components/LoadingSpinner' +import SecondaryCard from 'components/SecondaryCard' +import { TruncatedText } from 'components/TruncatedText' + +const ModuleIssueDetailsPage = () => { + const { issueId } = useParams() as { issueId: string } + + const { programKey, moduleKey } = useParams() as { + programKey: string + moduleKey: string + issueId: string + } + const { data, loading } = useQuery(GET_MODULE_ISSUE_VIEW, { + variables: { programKey, moduleKey, number: Number(issueId) }, + skip: !issueId, + fetchPolicy: 'cache-first', + nextFetchPolicy: 'cache-and-network', + }) + + const [assignIssue, { loading: assigning }] = useMutation(ASSIGN_ISSUE_TO_USER, { + refetchQueries: [ + { + query: GET_MODULE_ISSUE_VIEW, + variables: { programKey, moduleKey, number: Number(issueId) }, + }, + ], + awaitRefetchQueries: true, + }) + const [unassignIssue, { loading: unassigning }] = useMutation(UNASSIGN_ISSUE_FROM_USER, { + refetchQueries: [ + { + query: GET_MODULE_ISSUE_VIEW, + variables: { programKey, moduleKey, number: Number(issueId) }, + }, + ], + awaitRefetchQueries: true, + }) + + const issue = data?.getModule?.issueByNumber + + if (loading) return + if (!issue) + return + + const assignees = issue.assignees || [] + const labels = issue.labels || [] + const visibleLabels = labels.slice(0, 5) + const remainingLabels = labels.length - visibleLabels.length + + const getButtonClassName = (disabled: boolean) => + `inline-flex items-center justify-center rounded-md border p-1.5 text-sm ${ + disabled + ? 'cursor-not-allowed border-gray-300 text-gray-400 dark:border-gray-600' + : 'border-gray-300 hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800' + }` + + const labelButtonClassName = + 'rounded-lg border border-gray-400 px-3 py-1 text-sm hover:bg-gray-200 dark:border-gray-300 dark:hover:bg-gray-700' + + return ( +
+
+
+
+

+ +

+
+ {issue.organizationName}/{issue.repositoryName} • #{issue.number} +
+
+ + View on GitHub + +
+ + }> +
+ {issue.summary || 'No description.'} +
+
+ + }> +
+
+ Assigned:{' '} + {data?.getModule?.taskAssignedAt + ? new Date(data.getModule.taskAssignedAt).toLocaleDateString() + : 'Not assigned'} +
+
+ Deadline:{' '} + {data?.getModule?.taskDeadline + ? new Date(data.getModule.taskDeadline).toLocaleDateString() + : 'No deadline set'} +
+
+
+ +
+

+
+
+ +
+ Labels +
+

+
+ {visibleLabels.map((label, index) => ( + + ))} + {remainingLabels > 0 && ( + + )} +
+
+ +
+

+
+
+ +
+ Assignees +
+

+
+ {assignees.map((a) => ( +
+ + {a.login} + {a.login || a.name} + + +
+ ))} + {assignees.length === 0 && Unassigned} +
+
+ + +
+ {issue.pullRequests?.map((pr) => ( +
+
+ {pr.author?.login +
+ + {pr.title} + +
+ by {pr.author?.login || 'Unknown'} •{' '} + {new Date(pr.createdAt).toLocaleDateString()} +
+
+
+ + + View PR + +
+ )) || No linked pull requests.} +
+
+ +
+

+
+
+ +
+ Interested Users +
+

+
+ {(data?.getModule?.interestedUsers || []).map((u) => ( +
+
+ {u.login} + @{u.login} +
+ +
+ ))} + {(data?.getModule?.interestedUsers || []).length === 0 && ( + No interested users yet. + )} +
+
+
+
+ ) +} + +export default ModuleIssueDetailsPage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx new file mode 100644 index 0000000000..9a2cf8aaf5 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx @@ -0,0 +1,227 @@ +'use client' + +import { useQuery } from '@apollo/client' +import { Select, SelectItem } from '@heroui/select' +import Image from 'next/image' +import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useEffect, useMemo, useState } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { GET_MODULE_ISSUES } from 'server/queries/moduleQueries' +import type { Issue } from 'types/issue' +import LoadingSpinner from 'components/LoadingSpinner' +import { TruncatedText } from 'components/TruncatedText' + +const LABEL_ALL = 'all' + +const IssuesPage = () => { + const { programKey, moduleKey } = useParams() as { programKey: string; moduleKey: string } + const router = useRouter() + const searchParams = useSearchParams() + const [selectedLabel, setSelectedLabel] = useState(searchParams.get('label') || LABEL_ALL) + + const { data, loading, error } = useQuery(GET_MODULE_ISSUES, { + variables: { programKey, moduleKey }, + skip: !programKey || !moduleKey, + fetchPolicy: 'cache-and-network', + }) + + useEffect(() => { + if (error) handleAppError(error) + }, [error]) + + const moduleData = data?.getModule + const moduleIssues: Issue[] = useMemo(() => { + const issues = (moduleData?.issues || []).map((i) => ({ + author: i.author, + createdAt: i.createdAt, + hint: '', + labels: i.labels || [], + number: i.number, + organizationName: i.organizationName, + projectName: moduleData?.projectName || '', + projectUrl: '', + repository: undefined, + repositoryLanguages: [], + summary: '', + title: i.title, + updatedAt: i.createdAt, + url: i.url, + objectID: i.id, + })) + if (selectedLabel === LABEL_ALL) return issues + return issues.filter((iss) => (iss.labels || []).includes(selectedLabel)) + }, [moduleData, selectedLabel]) + + const allLabels: string[] = useMemo(() => { + const labels = new Set() + ;(moduleData?.issues || []).forEach((i) => + (i.labels || []).forEach((l: string) => labels.add(l)) + ) + return Array.from(labels).sort() + }, [moduleData]) + + const handleLabelChange = (label: string) => { + setSelectedLabel(label) + const params = new URLSearchParams(searchParams.toString()) + if (label === LABEL_ALL) { + params.delete('label') + } else { + params.set('label', label) + } + router.replace(`?${params.toString()}`) + } + + if (loading) return + if (!moduleData) + return + + return ( +
+
+
+

{moduleData.name} Issues

+
+ +
+
+ +
+ + + + + + + + + + {moduleIssues.map((issue) => ( + + + + + + ))} + {moduleIssues.length === 0 && ( + + + + )} + +
+ Title + + Labels + + Assignee +
+ + +
+ {(() => { + const labels = issue.labels || [] + const visible = labels.slice(0, 5) + const remaining = labels.length - visible.length + return ( + <> + {visible.map((label) => ( + + {label} + + ))} + {remaining > 0 && ( + + +{remaining} more + + )} + + ) + })()} +
+
+ {((assignees) => + assignees?.length ? ( +
+
+ {assignees[0].login} + {assignees[0].login || assignees[0].name} +
+ {assignees.length > 1 && ( +
+ +{assignees.length - 1} +
+ )} +
+ ) : ( + Unassigned + ))( + (data?.getModule?.issues || []).find((i) => i.id === issue.objectID) + ?.assignees + )} +
+ No issues found for the selected filter. +
+
+
+
+ ) +} + +export default IssuesPage diff --git a/frontend/src/server/queries/issueQueries.ts b/frontend/src/server/queries/issueQueries.ts new file mode 100644 index 0000000000..e1abd34661 --- /dev/null +++ b/frontend/src/server/queries/issueQueries.ts @@ -0,0 +1,89 @@ +import { gql } from '@apollo/client' + +export const GET_MODULE_ISSUE_VIEW = gql` + query GetModuleIssueView($programKey: String!, $moduleKey: String!, $number: Int!) { + getModule(programKey: $programKey, moduleKey: $moduleKey) { + id + taskDeadline(issueNumber: $number) + taskAssignedAt(issueNumber: $number) + issueByNumber(number: $number) { + id + number + title + summary + url + state + createdAt + organizationName + repositoryName + author { + id + login + name + avatarUrl + } + assignees { + id + login + name + avatarUrl + } + labels + pullRequests { + id + title + url + createdAt + author { + id + login + name + avatarUrl + } + } + } + interestedUsers(issueNumber: $number) { + id + login + name + avatarUrl + } + } + } +` + +export const ASSIGN_ISSUE_TO_USER = gql` + mutation AssignIssueToUser( + $programKey: String! + $moduleKey: String! + $issueNumber: Int! + $userLogin: String! + ) { + assignIssueToUser( + programKey: $programKey + moduleKey: $moduleKey + issueNumber: $issueNumber + userLogin: $userLogin + ) { + id + } + } +` + +export const UNASSIGN_ISSUE_FROM_USER = gql` + mutation UnassignIssueFromUser( + $programKey: String! + $moduleKey: String! + $issueNumber: Int! + $userLogin: String! + ) { + unassignIssueFromUser( + programKey: $programKey + moduleKey: $moduleKey + issueNumber: $issueNumber + userLogin: $userLogin + ) { + id + } + } +` diff --git a/frontend/src/server/queries/moduleQueries.ts b/frontend/src/server/queries/moduleQueries.ts index f10f76a978..70839f3d70 100644 --- a/frontend/src/server/queries/moduleQueries.ts +++ b/frontend/src/server/queries/moduleQueries.ts @@ -75,3 +75,46 @@ export const GET_PROGRAM_ADMINS_AND_MODULES = gql` } } ` + +export const GET_MODULE_ISSUES = gql` + query GetModuleIssues($programKey: String!, $moduleKey: String!) { + getModule(moduleKey: $moduleKey, programKey: $programKey) { + id + name + key + issues { + id + number + createdAt + title + summary + url + author { + id + avatarUrl + login + name + } + assignees { + id + avatarUrl + login + name + } + labels + pullRequests { + id + title + url + createdAt + author { + id + login + name + avatarUrl + } + } + } + } + } +` diff --git a/frontend/src/types/issue.ts b/frontend/src/types/issue.ts index bd8f746f0c..5be98aaf30 100644 --- a/frontend/src/types/issue.ts +++ b/frontend/src/types/issue.ts @@ -1,3 +1,4 @@ +import type { PullRequest } from 'types/pullRequest' import type { RepositoryDetails, User } from 'types/user' export type Issue = { @@ -9,6 +10,7 @@ export type Issue = { organizationName?: string projectName: string projectUrl: string + pullRequests?: PullRequest[] repository?: RepositoryDetails repositoryLanguages?: string[] summary: string diff --git a/frontend/src/types/pullRequest.ts b/frontend/src/types/pullRequest.ts index 47c15ad798..ccd796587c 100644 --- a/frontend/src/types/pullRequest.ts +++ b/frontend/src/types/pullRequest.ts @@ -1,6 +1,7 @@ import type { User } from 'types/user' export type PullRequest = { + id: string author: User createdAt: string organizationName: string