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..b0fa914dde 100644 --- a/backend/apps/github/api/internal/nodes/issue.py +++ b/backend/apps/github/api/internal/nodes/issue.py @@ -3,6 +3,7 @@ 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 @@ -11,7 +12,9 @@ Issue, fields=[ "created_at", + "number", "state", + "body", "title", "url", ], @@ -37,3 +40,33 @@ 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 is_merged(self) -> bool: + """Return True if this issue has at least one merged pull request.""" + return self.pull_requests.filter(state="closed", merged_at__isnull=False).exists() + + @strawberry.field + def interested_users(self) -> list[UserNode]: + """Return all users who have expressed interest in this issue.""" + return [ + interest.user + for interest in self.participant_interests.select_related("user").order_by( + "user__login" + ) + ] + + @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/api/internal/nodes/pull_request.py b/backend/apps/github/api/internal/nodes/pull_request.py index 8ac55e2221..78a2f280b6 100644 --- a/backend/apps/github/api/internal/nodes/pull_request.py +++ b/backend/apps/github/api/internal/nodes/pull_request.py @@ -12,6 +12,8 @@ fields=[ "created_at", "title", + "state", + "merged_at", ], ) class PullRequestNode(strawberry.relay.Node): 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/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..cd2639726b 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 .label import Label 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..15abe3f971 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,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 +95,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..392beaf751 100644 --- a/backend/apps/mentorship/api/internal/mutations/module.py +++ b/backend/apps/mentorship/api/internal/mutations/module.py @@ -1,5 +1,6 @@ """GraphQL mutations for mentorship modules in the mentorship app.""" +import datetime as dt import logging import strawberry @@ -14,6 +15,8 @@ UpdateModuleInput, ) from apps.mentorship.models import Mentor, Module, Program +from apps.mentorship.models.issue_user_interest import IssueUserInterest +from apps.mentorship.models.task import Task from apps.nest.api.internal.permissions import IsAuthenticated from apps.owasp.models import Project @@ -99,6 +102,7 @@ def create_module(self, info: strawberry.Info, input_data: CreateModuleInput) -> started_at=started_at, ended_at=ended_at, domains=input_data.domains, + labels=input_data.labels, tags=input_data.tags, program=program, project=project, @@ -114,6 +118,205 @@ 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 set_task_deadline( + self, + info: strawberry.Info, + *, + module_key: str, + program_key: str, + issue_number: int, + deadline_at: dt.datetime, + ) -> ModuleNode: + """Set a deadline for a task. User must be a mentor and an admin of the program.""" + 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 set deadlines.") + if not module.program.admins.filter(id=mentor.id).exists(): + raise PermissionDenied + + issue = ( + module.issues.select_related("repository") + .prefetch_related("assignees") + .filter(number=issue_number) + .first() + ) + if issue is None: + raise ObjectDoesNotExist(msg="Issue not found in this module.") + + assignees = issue.assignees.all() + if not assignees.exists(): + raise ValidationError(message="Cannot set deadline: issue has no assignees.") + + normalized_deadline = deadline_at + if timezone.is_naive(normalized_deadline): + normalized_deadline = timezone.make_aware(normalized_deadline) + + if normalized_deadline.date() < timezone.now().date(): + raise ValidationError(message="Deadline cannot be in the past.") + + now = timezone.now() + tasks_to_update: list[Task] = [] + for assignee in assignees: + task, _created = Task.objects.get_or_create( + module=module, issue=issue, assignee=assignee + ) + if task.assigned_at is None: + task.assigned_at = now + task.deadline_at = normalized_deadline + tasks_to_update.append(task) + + Task.objects.bulk_update(tasks_to_update, ["assigned_at", "deadline_at"]) + + return module + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + @transaction.atomic + def clear_task_deadline( + self, + info: strawberry.Info, + *, + module_key: str, + program_key: str, + issue_number: int, + ) -> ModuleNode: + """Clear the deadline for a task. User must be a mentor and an admin of the program.""" + 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 clear deadlines.") + if not module.program.admins.filter(id=mentor.id).exists(): + raise PermissionDenied + + issue = ( + module.issues.select_related("repository") + .prefetch_related("assignees") + .filter(number=issue_number) + .first() + ) + if issue is None: + raise ObjectDoesNotExist(msg="Issue not found in this module.") + + assignees = issue.assignees.all() + if not assignees.exists(): + raise ValidationError(message="Cannot clear deadline: issue has no assignees.") + + tasks_to_update: list[Task] = [] + for assignee in assignees: + task = Task.objects.filter(module=module, issue=issue, assignee=assignee).first() + if task and task.deadline_at is not None: + task.deadline_at = None + tasks_to_update.append(task) + + if len(tasks_to_update) == 1: + tasks_to_update[0].save(update_fields=["deadline_at"]) + elif len(tasks_to_update) > 1: + Task.objects.bulk_update(tasks_to_update, ["deadline_at"]) + + return module + @strawberry.mutation(permission_classes=[IsAuthenticated]) @transaction.atomic def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> ModuleNode: @@ -125,19 +328,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 @@ -158,6 +359,7 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> "started_at": started_at, "ended_at": ended_at, "domains": input_data.domains, + "labels": input_data.labels, "tags": input_data.tags, } diff --git a/backend/apps/mentorship/api/internal/nodes/module.py b/backend/apps/mentorship/api/internal/nodes/module.py index fa94ad4728..1531e074c0 100644 --- a/backend/apps/mentorship/api/internal/nodes/module.py +++ b/backend/apps/mentorship/api/internal/nodes/module.py @@ -4,9 +4,14 @@ import strawberry +from apps.github.api.internal.nodes.issue import IssueNode +from apps.github.api.internal.nodes.user import UserNode +from apps.github.models import Label 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 @@ -20,6 +25,7 @@ class ModuleNode: domains: list[str] | None = None ended_at: datetime experience_level: ExperienceLevelEnum + labels: list[str] | None = None program: ProgramNode | None = None project_id: strawberry.ID | None = None started_at: datetime @@ -35,6 +41,92 @@ 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, limit: int = 20, offset: int = 0, label: str | None = None + ) -> list[IssueNode]: + """Return paginated issues linked to this module, optionally filtered by label.""" + queryset = self.issues.select_related("repository", "author").prefetch_related( + "assignees", "labels" + ) + + if label and label != "all": + queryset = queryset.filter(labels__name=label) + + return list(queryset.order_by("-updated_at")[offset : offset + limit]) + + @strawberry.field + def issues_count(self, label: str | None = None) -> int: + """Return total count of issues linked to this module, optionally filtered by label.""" + queryset = self.issues + + if label and label != "all": + queryset = queryset.filter(labels__name=label) + + return queryset.count() + + @strawberry.field + def available_labels(self) -> list[str]: + """Return all unique labels from issues linked to this module.""" + label_names = ( + Label.objects.filter(issue__mentorship_modules=self) + .values_list("name", flat=True) + .distinct() + ) + + return sorted(label_names) + + @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 deadline for the latest assigned task linked to this module and issue.""" + return ( + Task.objects.filter( + module=self, + issue__number=issue_number, + deadline_at__isnull=False, + ) + .order_by("-assigned_at") + .values_list("deadline_at", flat=True) + .first() + ) + + @strawberry.field + def task_assigned_at(self, issue_number: int) -> datetime | None: + """Return the latest assignment time for tasks linked to this module and issue number.""" + return ( + Task.objects.filter( + module=self, + issue__number=issue_number, + assigned_at__isnull=False, + ) + .order_by("-assigned_at") + .values_list("assigned_at", flat=True) + .first() + ) + @strawberry.input class CreateModuleInput: @@ -45,6 +137,7 @@ class CreateModuleInput: domains: list[str] = strawberry.field(default_factory=list) ended_at: datetime experience_level: ExperienceLevelEnum + labels: list[str] = strawberry.field(default_factory=list) mentor_logins: list[str] | None = None program_key: str project_name: str @@ -64,6 +157,7 @@ class UpdateModuleInput: domains: list[str] = strawberry.field(default_factory=list) ended_at: datetime experience_level: ExperienceLevelEnum + labels: list[str] = strawberry.field(default_factory=list) mentor_logins: list[str] | None = None project_id: strawberry.ID project_name: str 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/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..8e9e9cd587 --- /dev/null +++ b/backend/apps/mentorship/management/commands/sync_module_issues.py @@ -0,0 +1,239 @@ +"""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 + + if 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 + self.stdout.write( + f"Updated assignment date for issue #{issue.number}" + ) + + 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/tests/apps/github/api/internal/nodes/issue_test.py b/backend/tests/apps/github/api/internal/nodes/issue_test.py index c5d59aa981..14e7261d19 100644 --- a/backend/tests/apps/github/api/internal/nodes/issue_test.py +++ b/backend/tests/apps/github/api/internal/nodes/issue_test.py @@ -16,12 +16,19 @@ def test_issue_node_fields(self): expected_field_names = { "_id", "created_at", + "number", "state", + "body", "title", "url", "author", "organization_name", "repository_name", + "assignees", + "labels", + "is_merged", + "interested_users", + "pull_requests", } assert field_names == expected_field_names diff --git a/backend/tests/apps/github/api/internal/nodes/pull_request_test.py b/backend/tests/apps/github/api/internal/nodes/pull_request_test.py index 90d54cdcff..e5cad0f0a0 100644 --- a/backend/tests/apps/github/api/internal/nodes/pull_request_test.py +++ b/backend/tests/apps/github/api/internal/nodes/pull_request_test.py @@ -17,8 +17,10 @@ def test_meta_configuration(self): "_id", "author", "created_at", + "merged_at", "organization_name", "repository_name", + "state", "title", "url", } diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 9a191d1b4a..1ac205c684 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -120,6 +120,7 @@ superfences tiktok tsc turbopack +unassigning unhover usefixtures winsrdf diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx index 784c320cb7..eb6d6576f4 100644 --- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx +++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx @@ -494,7 +494,7 @@ describe('CardDetailsPage', () => { organizationName: 'test-org', projectName: 'Test Project', projectUrl: 'https://github.com/test/project', - summary: 'Issue summary', + body: 'Issue summary', title: 'Test Issue', updatedAt: Date.now(), url: 'https://github.com/test/project/issues/123', @@ -518,11 +518,14 @@ describe('CardDetailsPage', () => { const mockPullRequests = [ { + id: 'mock-pull-request-1', author: mockUser, createdAt: new Date(Date.now() - 172800000).toISOString(), organizationName: 'test-org', title: 'Add new feature', url: 'https://github.com/test/project/pull/456', + state: 'merged', + mergedAt: new Date(Date.now() - 86400000).toISOString(), }, ] diff --git a/frontend/__tests__/unit/components/ItemCardList.test.tsx b/frontend/__tests__/unit/components/ItemCardList.test.tsx index 852a6e576d..09e7767a4c 100644 --- a/frontend/__tests__/unit/components/ItemCardList.test.tsx +++ b/frontend/__tests__/unit/components/ItemCardList.test.tsx @@ -148,6 +148,7 @@ const mockMilestone: Milestone = { } const mockPullRequest: PullRequest = { + id: 'mock-pull-request-id', author: { ...mockUser, login: 'author3', @@ -161,6 +162,8 @@ const mockPullRequest: PullRequest = { repositoryName: 'test-repo', title: 'Add new feature', url: 'https://github.com/test-org/test-repo/pull/456', + state: 'open', + mergedAt: '2022-01-02T00:00:00Z', } const mockRelease: Release = { diff --git a/frontend/__tests__/unit/components/RecentPullRequests.test.tsx b/frontend/__tests__/unit/components/RecentPullRequests.test.tsx index 1db4819d0a..02d029d383 100644 --- a/frontend/__tests__/unit/components/RecentPullRequests.test.tsx +++ b/frontend/__tests__/unit/components/RecentPullRequests.test.tsx @@ -40,23 +40,29 @@ const mockUser = { const minimalData = [ { + id: 'mock-pull-request', author: mockUser, createdAt: '2024-06-01T12:00:00Z', organizationName: 'test-org', repositoryName: 'test-repo', title: 'Test Pull Request', url: 'https://github.com/test-org/test-repo/pull/1', + state: 'open', + mergedAt: '2024-06-02T12:00:00Z', }, ] const noRepoData = [ { + id: 'mock-pull-request', author: mockUser, createdAt: '2024-06-01T12:00:00Z', organizationName: 'test-org', repositoryName: undefined, title: 'Test Pull Request', url: 'https://github.com/test-org/test-repo/pull/2', + state: 'open', + mergedAt: '2024-06-02T12:00:00Z', }, ] diff --git a/frontend/__tests__/unit/components/SingleModuleCard.test.tsx b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx index d5a9d56412..788e91050f 100644 --- a/frontend/__tests__/unit/components/SingleModuleCard.test.tsx +++ b/frontend/__tests__/unit/components/SingleModuleCard.test.tsx @@ -106,6 +106,7 @@ const mockModule: Module = { endedAt: '2024-12-31T23:59:59Z', domains: ['frontend', 'backend'], tags: ['react', 'nodejs'], + labels: ['good first issue', 'bug'], } const mockAdmins = [{ login: 'admin1' }, { login: 'admin2' }] diff --git a/frontend/__tests__/unit/data/mockOrganizationData.ts b/frontend/__tests__/unit/data/mockOrganizationData.ts index 5bb94f698e..49bd182716 100644 --- a/frontend/__tests__/unit/data/mockOrganizationData.ts +++ b/frontend/__tests__/unit/data/mockOrganizationData.ts @@ -76,6 +76,7 @@ export const mockOrganizationDetailsData = { ], recentPullRequests: [ { + id: 'mock-org-pr-1', title: 'Test Pull Request 1', createdAt: 1727390000, url: 'https://github.com/test-org/test-repo-1/pull/1', @@ -85,6 +86,7 @@ export const mockOrganizationDetailsData = { }, }, { + id: 'mock-org-pr-2', title: 'Test Pull Request 2', createdAt: 1727380000, url: 'https://github.com/test-org/test-repo-2/pull/2', diff --git a/frontend/__tests__/unit/data/mockProjectDetailsData.ts b/frontend/__tests__/unit/data/mockProjectDetailsData.ts index 9d9c195022..60a39362fc 100644 --- a/frontend/__tests__/unit/data/mockProjectDetailsData.ts +++ b/frontend/__tests__/unit/data/mockProjectDetailsData.ts @@ -101,6 +101,7 @@ export const mockProjectDetailsData = { ], recentPullRequests: [ { + id: 'mock-project-pr-1', author: { avatarUrl: 'https://avatars.githubusercontent.com/u/11111?v=4', login: 'user1', @@ -110,6 +111,7 @@ export const mockProjectDetailsData = { url: 'https://github.com/test-org/test-repo-1/pull/1', }, { + id: 'mock-project-pr-2', author: { avatarUrl: 'https://avatars.githubusercontent.com/u/22222?v=4', login: 'user2', diff --git a/frontend/__tests__/unit/data/mockRepositoryData.ts b/frontend/__tests__/unit/data/mockRepositoryData.ts index ca9f789a7b..812d6f963c 100644 --- a/frontend/__tests__/unit/data/mockRepositoryData.ts +++ b/frontend/__tests__/unit/data/mockRepositoryData.ts @@ -62,6 +62,7 @@ export const mockRepositoryData = { }, recentPullRequests: [ { + id: 'mock-repo-pr-1', title: 'Test Pull Request 1', createdAt: 1727390000, url: 'https://github.com/test-org/test-repo-1/pull/1', @@ -71,6 +72,7 @@ export const mockRepositoryData = { }, }, { + id: 'mock-repo-pr-2', title: 'Test Pull Request 2', createdAt: 1727380000, url: 'https://github.com/test-org/test-repo-2/pull/2', diff --git a/frontend/__tests__/unit/data/mockUserDetails.ts b/frontend/__tests__/unit/data/mockUserDetails.ts index a082f47170..fa82456f68 100644 --- a/frontend/__tests__/unit/data/mockUserDetails.ts +++ b/frontend/__tests__/unit/data/mockUserDetails.ts @@ -45,6 +45,7 @@ export const mockUserDetailsData = { ], recentPullRequests: [ { + id: 'mock-user-pr-1', createdAt: 1723002473, repositoryName: 'test-repo-3', title: 'Test Pull Request', diff --git a/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx index 57e7b1b84c..b5740f14dc 100644 --- a/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx +++ b/frontend/src/app/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx @@ -65,6 +65,7 @@ const ModuleDetailsPage = () => { admins={admins} tags={module.tags} domains={module.domains} + labels={module.labels} summary={module.description} mentors={module.mentors} type="module" diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx index bf8be545a5..9599db7c93 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/edit/page.tsx @@ -82,6 +82,7 @@ const EditModulePage = () => { domains: (m.domains || []).join(', '), projectName: m.projectName, tags: (m.tags || []).join(', '), + labels: (m.labels || []).join(', '), projectId: m.projectId || '', mentorLogins: (m.mentors || []).map((mentor: { login: string }) => mentor.login).join(', '), }) @@ -103,6 +104,7 @@ const EditModulePage = () => { endedAt: formData.endedAt || null, domains: parseCommaSeparated(formData.domains), tags: parseCommaSeparated(formData.tags), + labels: parseCommaSeparated(formData.labels), projectName: formData.projectName, projectId: formData.projectId, mentorLogins: parseCommaSeparated(formData.mentorLogins), 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..56c0544cf7 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/[issueId]/page.tsx @@ -0,0 +1,444 @@ +'use client' + +import { useQuery } from '@apollo/client' +import { + faCodeBranch, + faLink, + faPlus, + faTags, + faUsers, + faXmark, +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { useIssueMutations } from 'hooks/useIssueMutations' +import Image from 'next/image' +import Link from 'next/link' +import { useParams } from 'next/navigation' +import { ErrorDisplay } from 'app/global-error' +import { GET_MODULE_ISSUE_VIEW } from 'server/queries/issueQueries' +import ActionButton from 'components/ActionButton' +import AnchorTitle from 'components/AnchorTitle' +import LoadingSpinner from 'components/LoadingSpinner' +import Markdown from 'components/MarkdownWrapper' +import SecondaryCard from 'components/SecondaryCard' +import { TruncatedText } from 'components/TruncatedText' + +const ModuleIssueDetailsPage = () => { + const params = useParams() as { programKey: string; moduleKey: string; issueId: string } + const { programKey, moduleKey, issueId } = params + + const formatDeadline = (deadline: string | null) => { + if (!deadline) return { text: 'No deadline set', color: 'text-gray-600 dark:text-gray-300' } + + const deadlineDate = new Date(deadline) + const today = new Date() + + const deadlineUTC = new Date( + deadlineDate.getUTCFullYear(), + deadlineDate.getUTCMonth(), + deadlineDate.getUTCDate() + ) + const todayUTC = new Date(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) + + const isOverdue = deadlineUTC < todayUTC + const daysLeft = Math.ceil((deadlineUTC.getTime() - todayUTC.getTime()) / (1000 * 60 * 60 * 24)) + + const statusText = isOverdue + ? '(overdue)' + : daysLeft === 0 + ? '(today)' + : `(${daysLeft} days left)` + + const displayDate = deadlineDate.toLocaleDateString() + + return { + text: `${displayDate} ${statusText}`, + color: isOverdue + ? 'text-[#DA3633]' + : daysLeft <= 3 + ? 'text-[#F59E0B]' + : 'text-gray-600 dark:text-gray-300', + } + } + const { data, loading, error } = useQuery(GET_MODULE_ISSUE_VIEW, { + variables: { programKey, moduleKey, number: Number(issueId) }, + skip: !issueId, + fetchPolicy: 'cache-first', + nextFetchPolicy: 'cache-and-network', + }) + + const { + assignIssue, + unassignIssue, + setTaskDeadlineMutation, + clearTaskDeadlineMutation, + assigning, + unassigning, + settingDeadline, + clearingDeadline, + isEditingDeadline, + setIsEditingDeadline, + deadlineInput, + setDeadlineInput, + } = useIssueMutations({ programKey, moduleKey, issueId }) + + const issue = data?.getModule?.issueByNumber + const taskDeadline = data?.getModule?.taskDeadline as string | undefined + + 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' + + if (error) { + return + } + 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 canEditDeadline = assignees.length > 0 + + return ( +
+
+
+
+

+ {issue.title} +

+
+ + {issue.organizationName}/{issue.repositoryName} • #{issue.number} + + + {issue.state === 'open' ? 'Open' : issue.isMerged ? 'Merged' : 'Closed'} + +
+
+ + View on GitHub + +
+ + }> +
+ +
+
+ + }> +
+
+ Assigned:{' '} + + {data?.getModule?.taskAssignedAt + ? new Date(data.getModule.taskAssignedAt).toLocaleDateString() + : 'Not assigned'} + +
+ +
+
+ Deadline: + {isEditingDeadline && canEditDeadline ? ( +
+ { + const newValue = e.target.value + setDeadlineInput(newValue) + + if (!settingDeadline && !clearingDeadline && issueId) { + if (newValue) { + const [year, month, day] = newValue.split('-').map(Number) + const utcEndOfDay = new Date( + Date.UTC(year, month - 1, day, 23, 59, 59, 999) + ) + const iso = utcEndOfDay.toISOString() + + await setTaskDeadlineMutation({ + variables: { + programKey, + moduleKey, + issueNumber: Number(issueId), + deadlineAt: iso, + }, + }) + } else { + // Clear deadline + await clearTaskDeadlineMutation({ + variables: { + programKey, + moduleKey, + issueNumber: Number(issueId), + }, + }) + } + } + }} + min={new Date().toISOString().slice(0, 10)} + className="h-8 rounded border border-gray-300 px-2 dark:border-gray-600" + /> +
+ ) : ( + + )} +
+
+
+
+ +
+

+
+
+ +
+ Labels +
+

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

+
+
+ +
+ Assignees +
+

+
+ {assignees.map((a) => ( +
+ + {a.avatarUrl ? ( + {a.login} + ) : ( + + ))} +
+
+ )} + + +
+ {issue.pullRequests?.length ? ( + issue.pullRequests.map((pr) => ( +
+
+ {pr.author?.avatarUrl ? ( + {pr.author?.login + ) : ( + +
+ {pr.state === 'closed' && pr.mergedAt ? ( + + Merged + + ) : pr.state === 'closed' ? ( + + Closed + + ) : ( + + Open + + )} +
+
+ )) + ) : ( + No linked pull requests. + )} +
+ + +
+

+
+
+ +
+ Interested Users +
+

+
+ {(data?.getModule?.interestedUsers || []).map((u) => ( +
+
+ {u.avatarUrl ? ( + {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..ae82173356 --- /dev/null +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx @@ -0,0 +1,357 @@ +'use client' + +import { useQuery } from '@apollo/client' +import { Select, SelectItem } from '@heroui/select' +import { Tooltip } from '@heroui/tooltip' +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 LoadingSpinner from 'components/LoadingSpinner' +import Pagination from 'components/Pagination' + +const LABEL_ALL = 'all' +const ITEMS_PER_PAGE = 20 +const MAX_VISIBLE_LABELS = 5 + +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 [currentPage, setCurrentPage] = useState(1) + + const { data, loading, error } = useQuery(GET_MODULE_ISSUES, { + variables: { + programKey, + moduleKey, + limit: ITEMS_PER_PAGE, + offset: (currentPage - 1) * ITEMS_PER_PAGE, + label: selectedLabel === LABEL_ALL ? null : selectedLabel, + }, + skip: !programKey || !moduleKey, + fetchPolicy: 'cache-and-network', + }) + + useEffect(() => { + if (error) handleAppError(error) + }, [error]) + + const moduleData = data?.getModule + type ModuleIssueRow = { + objectID: string + number: number + title: string + state: string + isMerged: boolean + labels: string[] + assignees: Array<{ avatarUrl: string; login: string; name: string }> + } + + const moduleIssues: ModuleIssueRow[] = useMemo(() => { + return (moduleData?.issues || []).map((i) => ({ + objectID: i.id, + number: i.number, + title: i.title, + state: i.state, + isMerged: i.isMerged, + labels: i.labels || [], + assignees: i.assignees || [], + })) + }, [moduleData]) + + const totalPages = Math.ceil((moduleData?.issuesCount || 0) / ITEMS_PER_PAGE) + + const allLabels: string[] = useMemo(() => { + const serverLabels = moduleData?.availableLabels + if (serverLabels && serverLabels.length > 0) { + return serverLabels + } + + const labels = new Set() + ;(moduleData?.issues || []).forEach((i) => + (i.labels || []).forEach((l: string) => labels.add(l)) + ) + return Array.from(labels).sort((a, b) => a.localeCompare(b)) + }, [moduleData]) + + const handleLabelChange = (label: string) => { + setSelectedLabel(label) + setCurrentPage(1) + const params = new URLSearchParams(searchParams.toString()) + if (label === LABEL_ALL) { + params.delete('label') + } else { + params.set('label', label) + } + router.replace(`?${params.toString()}`) + } + + const handlePageChange = (page: number) => { + setCurrentPage(page) + } + + const handleIssueClick = (issueNumber: number) => { + router.push(`/my/mentorship/programs/${programKey}/modules/${moduleKey}/issues/${issueNumber}`) + } + + if (loading) return + if (!moduleData) + return + + return ( +
+
+
+

{moduleData.name} Issues

+
+ +
+
+ + {/* Desktop Table - unchanged */} +
+ + + + + + + + + + + {moduleIssues.map((issue) => ( + + + + + + + ))} + {moduleIssues.length === 0 && ( + + + + )} + +
+ Title + + Status + + Labels + + Assignee +
+ 50 ? false : true} + > + + + +
+ + {issue.state === 'open' ? 'Open' : issue.isMerged ? 'Merged' : 'Closed'} + +
+
+
+ {(() => { + const labels = issue.labels || [] + const visible = labels.slice(0, MAX_VISIBLE_LABELS) + const remaining = labels.length - visible.length + return ( + <> + {visible.map((label) => ( + + {label} + + ))} + {remaining > 0 && ( + + +{remaining} more + + )} + + ) + })()} +
+
+ {issue.assignees?.length ? ( +
+
+ {issue.assignees[0].login} + + {issue.assignees[0].login || issue.assignees[0].name} + +
+ {issue.assignees.length > 1 && ( +
+ +{issue.assignees.length - 1} +
+ )} +
+ ) : null} +
+ No issues found for the selected filter. +
+
+ + {/* Mobile & Tablet Cards */} +
+ {moduleIssues.map((issue) => ( +
+
+ + + {issue.state === 'open' ? 'Open' : issue.isMerged ? 'Merged' : 'Closed'} + +
+ + {issue.labels?.length > 0 && ( +
+ {issue.labels.slice(0, 3).map((label) => ( + + {label} + + ))} + {issue.labels.length > 3 && ( + + +{issue.labels.length - 3} + + )} +
+ )} + + {issue.assignees?.length > 0 && ( +
+ {issue.assignees[0].login} + + {issue.assignees[0].login || issue.assignees[0].name} + {issue.assignees.length > 1 && ` +${issue.assignees.length - 1}`} + +
+ )} +
+ ))} + + {moduleIssues.length === 0 && ( +
+

+ No issues found for the selected filter. +

+
+ )} +
+ + {/* Pagination Controls */} + +
+
+ ) +} + +export default IssuesPage diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx index dc0a7356b9..7ac149c976 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/page.tsx @@ -65,6 +65,7 @@ const ModuleDetailsPage = () => { admins={admins} tags={module.tags} domains={module.domains} + labels={module.labels} summary={module.description} mentors={module.mentors} type="module" diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx index eb75e88784..d55f53ebb3 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/create/page.tsx @@ -39,6 +39,7 @@ const CreateModulePage = () => { endedAt: '', domains: '', tags: '', + labels: '', projectId: '', projectName: '', mentorLogins: '', @@ -88,6 +89,7 @@ const CreateModulePage = () => { endedAt: formData.endedAt || null, domains: parseCommaSeparated(formData.domains), tags: parseCommaSeparated(formData.tags), + labels: parseCommaSeparated(formData.labels), programKey: programKey, projectId: formData.projectId, projectName: formData.projectName, diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx index 2ba935db21..c80a667fa4 100644 --- a/frontend/src/components/CardDetailsPage.tsx +++ b/frontend/src/components/CardDetailsPage.tsx @@ -43,6 +43,7 @@ const DetailsCard = ({ canUpdateStatus, tags, domains, + labels, modules, mentors, admins, @@ -84,15 +85,26 @@ const DetailsCard = ({ admins?.some( (admin) => admin.login === ((data as ExtendedSession)?.user?.login as string) ) && ( - +
+ + +
)} {IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && ( )} {(type === 'program' || type === 'module') && ( -
- {tags?.length > 0 && ( - } - isDisabled={true} - /> - )} - {domains?.length > 0 && ( - } - isDisabled={true} - /> + <> +
+ {tags?.length > 0 && ( + } + isDisabled={true} + /> + )} + {domains?.length > 0 && ( + } + isDisabled={true} + /> + )} +
+ {labels?.length > 0 && ( +
+ } + isDisabled={true} + /> +
)} -
+ )} {topContributors && ( = ({ label, className = '' }) => { + return ( + + {label} + + ) +} + +interface LabelListProps { + labels: string[] + maxVisible?: number + className?: string +} + +const LabelList: React.FC = ({ labels, maxVisible = 5, className = '' }) => { + if (!labels || labels.length === 0) return null + + const visibleLabels = labels.slice(0, maxVisible) + const remainingCount = labels.length - maxVisible + + return ( +
+ {visibleLabels.map((label, index) => ( +
+ ) +} + +export { LabelList } diff --git a/frontend/src/components/ModuleCard.tsx b/frontend/src/components/ModuleCard.tsx index 2ec6d2f562..d1cd84ddd1 100644 --- a/frontend/src/components/ModuleCard.tsx +++ b/frontend/src/components/ModuleCard.tsx @@ -8,10 +8,12 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import upperFirst from 'lodash/upperFirst' import Link from 'next/link' +import { usePathname } from 'next/navigation' import { useState } from 'react' import type { Module } from 'types/mentorship' import { formatDate } from 'utils/dateFormatter' import { TextInfoItem } from 'components/InfoItem' +import { LabelList } from 'components/LabelList' import SingleModuleCard from 'components/SingleModuleCard' import { TruncatedText } from 'components/TruncatedText' @@ -68,10 +70,11 @@ const ModuleCard = ({ modules, accessLevel, admins }: ModuleCardProps) => { } const ModuleItem = ({ details }: { details: Module }) => { + const pathname = usePathname() return (
@@ -83,6 +86,11 @@ const ModuleItem = ({ details }: { details: Module }) => { label="Duration" value={getSimpleDuration(details.startedAt, details.endedAt)} /> + {details.labels && details.labels.length > 0 && ( +
+ +
+ )}
) } diff --git a/frontend/src/components/ModuleForm.tsx b/frontend/src/components/ModuleForm.tsx index 9635df159b..9b1913f609 100644 --- a/frontend/src/components/ModuleForm.tsx +++ b/frontend/src/components/ModuleForm.tsx @@ -16,6 +16,7 @@ interface ModuleFormProps { endedAt: string domains: string tags: string + labels: string projectId: string projectName: string mentorLogins: string @@ -185,6 +186,20 @@ const ModuleForm = ({ className="w-full rounded-lg border border-gray-600 bg-gray-50 px-4 py-3 text-gray-800 focus:border-[#1D7BD7] focus:outline-none focus-visible:ring-1 focus-visible:ring-[#1D7BD7] dark:bg-gray-800 dark:text-gray-200 dark:focus-visible:ring-[#1D7BD7]" />
+
+ + +
= ({ program, onView, accessLevel, showArrow content={program.name} placement="bottom" - className="w-88" isDisabled={program.name.length > 50 ? false : true} >

diff --git a/frontend/src/components/SingleModuleCard.tsx b/frontend/src/components/SingleModuleCard.tsx index ade79a3e8d..2cb2f63a51 100644 --- a/frontend/src/components/SingleModuleCard.tsx +++ b/frontend/src/components/SingleModuleCard.tsx @@ -45,6 +45,11 @@ const SingleModuleCard: React.FC = ({ router.push(`${window.location.pathname}/modules/create`) } + const handleIssue = () => { + setDropdownOpen(false) + router.push(`${window.location.pathname}/modules/${module.key}/issues`) + } + const moduleDetails = [ { label: 'Experience Level', value: upperFirst(module.experienceLevel) }, { label: 'Start Date', value: formatDate(module.startedAt) }, @@ -111,6 +116,14 @@ const SingleModuleCard: React.FC = ({ Create Module )} + {isAdmin && ( + + )}

)}
diff --git a/frontend/src/hooks/useIssueMutations.ts b/frontend/src/hooks/useIssueMutations.ts new file mode 100644 index 0000000000..307506c697 --- /dev/null +++ b/frontend/src/hooks/useIssueMutations.ts @@ -0,0 +1,144 @@ +import { useMutation } from '@apollo/client' +import { addToast } from '@heroui/toast' +import { useState } from 'react' +import { + ASSIGN_ISSUE_TO_USER, + CLEAR_TASK_DEADLINE, + GET_MODULE_ISSUE_VIEW, + SET_TASK_DEADLINE, + UNASSIGN_ISSUE_FROM_USER, +} from 'server/queries/issueQueries' + +interface UseIssueMutationsProps { + programKey: string + moduleKey: string + issueId: string +} + +export const useIssueMutations = ({ programKey, moduleKey, issueId }: UseIssueMutationsProps) => { + const [isEditingDeadline, setIsEditingDeadline] = useState(false) + const [deadlineInput, setDeadlineInput] = useState('') + + const commonMutationConfig = { + refetchQueries: [ + { + query: GET_MODULE_ISSUE_VIEW, + variables: { programKey, moduleKey, number: Number(issueId) }, + }, + ], + awaitRefetchQueries: true, + } + + const [assignIssue, { loading: assigning }] = useMutation(ASSIGN_ISSUE_TO_USER, { + ...commonMutationConfig, + onCompleted: () => { + addToast({ + title: 'Issue assigned successfully', + variant: 'solid', + color: 'success', + timeout: 3000, + shouldShowTimeoutProgress: true, + }) + }, + onError: (error) => { + addToast({ + title: 'Failed to assign issue: ' + error.message, + variant: 'solid', + color: 'danger', + timeout: 3000, + shouldShowTimeoutProgress: true, + }) + }, + }) + + const [unassignIssue, { loading: unassigning }] = useMutation(UNASSIGN_ISSUE_FROM_USER, { + ...commonMutationConfig, + onCompleted: () => { + addToast({ + title: 'Issue unassigned successfully', + variant: 'solid', + color: 'success', + timeout: 3000, + shouldShowTimeoutProgress: true, + }) + }, + onError: (error) => { + addToast({ + title: 'Failed to unassign issue: ' + error.message, + variant: 'solid', + color: 'danger', + timeout: 3000, + shouldShowTimeoutProgress: true, + }) + }, + }) + + const [setTaskDeadlineMutation, { loading: settingDeadline }] = useMutation(SET_TASK_DEADLINE, { + ...commonMutationConfig, + onCompleted: () => { + addToast({ + title: 'Deadline updated', + variant: 'solid', + color: 'success', + timeout: 2500, + shouldShowTimeoutProgress: true, + }) + setIsEditingDeadline(false) + }, + onError: (err) => { + addToast({ + title: 'Failed to update deadline: ' + err.message, + variant: 'solid', + color: 'danger', + timeout: 3500, + shouldShowTimeoutProgress: true, + }) + }, + }) + + const [clearTaskDeadlineMutation, { loading: clearingDeadline }] = useMutation( + CLEAR_TASK_DEADLINE, + { + ...commonMutationConfig, + onCompleted: () => { + addToast({ + title: 'Deadline cleared', + variant: 'solid', + color: 'success', + timeout: 2500, + shouldShowTimeoutProgress: true, + }) + setIsEditingDeadline(false) + }, + onError: (err) => { + addToast({ + title: 'Failed to clear deadline: ' + err.message, + variant: 'solid', + color: 'danger', + timeout: 3500, + shouldShowTimeoutProgress: true, + }) + }, + } + ) + + return { + // Mutations + assignIssue, + unassignIssue, + setTaskDeadlineMutation, + clearTaskDeadlineMutation, + + // Loading states + assigning, + unassigning, + settingDeadline, + clearingDeadline, + + // Deadline state + isEditingDeadline, + setIsEditingDeadline, + deadlineInput, + setDeadlineInput, + } +} diff --git a/frontend/src/server/mutations/moduleMutations.ts b/frontend/src/server/mutations/moduleMutations.ts index f7da058aeb..c2761980ca 100644 --- a/frontend/src/server/mutations/moduleMutations.ts +++ b/frontend/src/server/mutations/moduleMutations.ts @@ -12,6 +12,7 @@ export const UPDATE_MODULE = gql` endedAt tags domains + labels projectId mentors { id @@ -35,6 +36,7 @@ export const CREATE_MODULE = gql` endedAt domains tags + labels projectId mentors { id diff --git a/frontend/src/server/queries/issueQueries.ts b/frontend/src/server/queries/issueQueries.ts new file mode 100644 index 0000000000..b5f283c97c --- /dev/null +++ b/frontend/src/server/queries/issueQueries.ts @@ -0,0 +1,111 @@ +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 + body + url + state + isMerged + organizationName + repositoryName + assignees { + id + login + name + avatarUrl + } + labels + pullRequests { + id + title + url + state + createdAt + mergedAt + 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 + } + } +` + +export const SET_TASK_DEADLINE = gql` + mutation SetTaskDeadline( + $programKey: String! + $moduleKey: String! + $issueNumber: Int! + $deadlineAt: DateTime! + ) { + setTaskDeadline( + programKey: $programKey + moduleKey: $moduleKey + issueNumber: $issueNumber + deadlineAt: $deadlineAt + ) { + id + } + } +` + +export const CLEAR_TASK_DEADLINE = gql` + mutation ClearTaskDeadline($programKey: String!, $moduleKey: String!, $issueNumber: Int!) { + clearTaskDeadline(programKey: $programKey, moduleKey: $moduleKey, issueNumber: $issueNumber) { + id + } + } +` diff --git a/frontend/src/server/queries/moduleQueries.ts b/frontend/src/server/queries/moduleQueries.ts index f10f76a978..5d4e8727e2 100644 --- a/frontend/src/server/queries/moduleQueries.ts +++ b/frontend/src/server/queries/moduleQueries.ts @@ -60,6 +60,7 @@ export const GET_PROGRAM_ADMINS_AND_MODULES = gql` name description tags + labels projectId projectName domains @@ -75,3 +76,32 @@ export const GET_PROGRAM_ADMINS_AND_MODULES = gql` } } ` + +export const GET_MODULE_ISSUES = gql` + query GetModuleIssues( + $programKey: String! + $moduleKey: String! + $limit: Int = 20 + $offset: Int = 0 + $label: String + ) { + getModule(moduleKey: $moduleKey, programKey: $programKey) { + name + issuesCount(label: $label) + availableLabels + issues(limit: $limit, offset: $offset, label: $label) { + id + number + title + state + isMerged + labels + assignees { + avatarUrl + login + name + } + } + } + } +` diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index 17f2ff0c06..adb2c61067 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -102,6 +102,7 @@ export type CreateModuleInput = { domains?: Array; endedAt: Scalars['DateTime']['input']; experienceLevel: ExperienceLevelEnum; + labels?: Array; mentorLogins?: InputMaybe>; name: Scalars['String']['input']; programKey: Scalars['String']['input']; @@ -182,11 +183,17 @@ export type GitHubAuthResult = { export type IssueNode = Node & { __typename?: 'IssueNode'; + assignees: Array; author?: Maybe; + body: Scalars['String']['output']; createdAt: Scalars['DateTime']['output']; /** The Globally Unique ID of this object */ id: Scalars['ID']['output']; + interestedUsers: Array; + labels: Array; + number: Scalars['Int']['output']; organizationName?: Maybe; + pullRequests: Array; repositoryName?: Maybe; state: Scalars['String']['output']; title: Scalars['String']['output']; @@ -227,12 +234,18 @@ export type MilestoneNode = Node & { export type ModuleNode = { __typename?: 'ModuleNode'; + availableLabels: Array; description: Scalars['String']['output']; domains?: Maybe>; endedAt: Scalars['DateTime']['output']; experienceLevel: ExperienceLevelEnum; id: Scalars['ID']['output']; + interestedUsers: Array; + issueByNumber?: Maybe; + issues: Array; + issuesCount: Scalars['Int']['output']; key: Scalars['String']['output']; + labels?: Maybe>; mentors: Array; name: Scalars['String']['output']; program?: Maybe; @@ -240,22 +253,66 @@ export type ModuleNode = { projectName?: Maybe; startedAt: Scalars['DateTime']['output']; tags?: Maybe>; + taskAssignedAt?: Maybe; + taskDeadline?: Maybe; +}; + + +export type ModuleNodeInterestedUsersArgs = { + issueNumber: Scalars['Int']['input']; +}; + + +export type ModuleNodeIssueByNumberArgs = { + number: Scalars['Int']['input']; +}; + + +export type ModuleNodeIssuesArgs = { + label?: InputMaybe; + limit?: Scalars['Int']['input']; + offset?: Scalars['Int']['input']; +}; + + +export type ModuleNodeIssuesCountArgs = { + label?: InputMaybe; +}; + + +export type ModuleNodeTaskAssignedAtArgs = { + issueNumber: Scalars['Int']['input']; +}; + + +export type ModuleNodeTaskDeadlineArgs = { + issueNumber: Scalars['Int']['input']; }; export type Mutation = { __typename?: 'Mutation'; + assignIssueToUser: ModuleNode; createApiKey: CreateApiKeyResult; createModule: ModuleNode; createProgram: ProgramNode; githubAuth: GitHubAuthResult; logoutUser: LogoutResult; revokeApiKey: RevokeApiKeyResult; + unassignIssueFromUser: ModuleNode; updateModule: ModuleNode; updateProgram: ProgramNode; updateProgramStatus: ProgramNode; }; +export type MutationAssignIssueToUserArgs = { + issueNumber: Scalars['Int']['input']; + moduleKey: Scalars['String']['input']; + programKey: Scalars['String']['input']; + userLogin: Scalars['String']['input']; +}; + + export type MutationCreateApiKeyArgs = { expiresAt: Scalars['DateTime']['input']; name: Scalars['String']['input']; @@ -282,6 +339,14 @@ export type MutationRevokeApiKeyArgs = { }; +export type MutationUnassignIssueFromUserArgs = { + issueNumber: Scalars['Int']['input']; + moduleKey: Scalars['String']['input']; + programKey: Scalars['String']['input']; + userLogin: Scalars['String']['input']; +}; + + export type MutationUpdateModuleArgs = { inputData: UpdateModuleInput; }; @@ -495,8 +560,10 @@ export type PullRequestNode = Node & { createdAt: Scalars['DateTime']['output']; /** The Globally Unique ID of this object */ id: Scalars['ID']['output']; + mergedAt?: Maybe; organizationName?: Maybe; repositoryName?: Maybe; + state: Scalars['String']['output']; title: Scalars['String']['output']; url: Scalars['String']['output']; }; @@ -828,6 +895,7 @@ export type UpdateModuleInput = { endedAt: Scalars['DateTime']['input']; experienceLevel: ExperienceLevelEnum; key: Scalars['String']['input']; + labels?: Array; mentorLogins?: InputMaybe>; name: Scalars['String']['input']; programKey: Scalars['String']['input']; diff --git a/frontend/src/types/__generated__/issueQueries.generated.ts b/frontend/src/types/__generated__/issueQueries.generated.ts new file mode 100644 index 0000000000..927c67dda9 --- /dev/null +++ b/frontend/src/types/__generated__/issueQueries.generated.ts @@ -0,0 +1,36 @@ +import * as Types from './graphql'; + +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type GetModuleIssueViewQueryVariables = Types.Exact<{ + programKey: Types.Scalars['String']['input']; + moduleKey: Types.Scalars['String']['input']; + number: Types.Scalars['Int']['input']; +}>; + + +export type GetModuleIssueViewQuery = { getModule: { __typename: 'ModuleNode', id: string, taskDeadline: unknown | null, taskAssignedAt: unknown | null, issueByNumber: { __typename: 'IssueNode', id: string, number: number, title: string, body: string, url: string, state: string, organizationName: string | null, repositoryName: string | null, labels: Array, assignees: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }>, pullRequests: Array<{ __typename: 'PullRequestNode', id: string, title: string, url: string, state: string, createdAt: unknown, mergedAt: unknown | null, author: { __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string } | null }> } | null, interestedUsers: Array<{ __typename: 'UserNode', id: string, login: string, name: string, avatarUrl: string }> } }; + +export type AssignIssueToUserMutationVariables = Types.Exact<{ + programKey: Types.Scalars['String']['input']; + moduleKey: Types.Scalars['String']['input']; + issueNumber: Types.Scalars['Int']['input']; + userLogin: Types.Scalars['String']['input']; +}>; + + +export type AssignIssueToUserMutation = { assignIssueToUser: { __typename: 'ModuleNode', id: string } }; + +export type UnassignIssueFromUserMutationVariables = Types.Exact<{ + programKey: Types.Scalars['String']['input']; + moduleKey: Types.Scalars['String']['input']; + issueNumber: Types.Scalars['Int']['input']; + userLogin: Types.Scalars['String']['input']; +}>; + + +export type UnassignIssueFromUserMutation = { unassignIssueFromUser: { __typename: 'ModuleNode', id: string } }; + + +export const GetModuleIssueViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModuleIssueView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"number"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"taskDeadline"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"issueNumber"},"value":{"kind":"Variable","name":{"kind":"Name","value":"number"}}}]},{"kind":"Field","name":{"kind":"Name","value":"taskAssignedAt"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"issueNumber"},"value":{"kind":"Variable","name":{"kind":"Name","value":"number"}}}]},{"kind":"Field","name":{"kind":"Name","value":"issueByNumber"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"number"},"value":{"kind":"Variable","name":{"kind":"Name","value":"number"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"pullRequests"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"mergedAt"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"interestedUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"issueNumber"},"value":{"kind":"Variable","name":{"kind":"Name","value":"number"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; +export const AssignIssueToUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AssignIssueToUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"issueNumber"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userLogin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"assignIssueToUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"issueNumber"},"value":{"kind":"Variable","name":{"kind":"Name","value":"issueNumber"}}},{"kind":"Argument","name":{"kind":"Name","value":"userLogin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userLogin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; +export const UnassignIssueFromUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UnassignIssueFromUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"issueNumber"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userLogin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unassignIssueFromUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"issueNumber"},"value":{"kind":"Variable","name":{"kind":"Name","value":"issueNumber"}}},{"kind":"Argument","name":{"kind":"Name","value":"userLogin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userLogin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/moduleMutations.generated.ts b/frontend/src/types/__generated__/moduleMutations.generated.ts index c3d8ee0463..8ae7ff275e 100644 --- a/frontend/src/types/__generated__/moduleMutations.generated.ts +++ b/frontend/src/types/__generated__/moduleMutations.generated.ts @@ -6,15 +6,15 @@ export type UpdateModuleMutationVariables = Types.Exact<{ }>; -export type UpdateModuleMutation = { updateModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, experienceLevel: Types.ExperienceLevelEnum, startedAt: unknown, endedAt: unknown, tags: Array | null, domains: Array | null, projectId: string | null, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> } }; +export type UpdateModuleMutation = { updateModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, experienceLevel: Types.ExperienceLevelEnum, startedAt: unknown, endedAt: unknown, tags: Array | null, domains: Array | null, labels: Array | null, projectId: string | null, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> } }; export type CreateModuleMutationVariables = Types.Exact<{ input: Types.CreateModuleInput; }>; -export type CreateModuleMutation = { createModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, experienceLevel: Types.ExperienceLevelEnum, startedAt: unknown, endedAt: unknown, domains: Array | null, tags: Array | null, projectId: string | null, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> } }; +export type CreateModuleMutation = { createModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, experienceLevel: Types.ExperienceLevelEnum, startedAt: unknown, endedAt: unknown, domains: Array | null, tags: Array | null, labels: Array | null, projectId: string | null, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> } }; -export const UpdateModuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateModule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateModuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; -export const CreateModuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateModule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateModuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const UpdateModuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateModule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateModuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateModuleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateModule"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateModuleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/moduleQueries.generated.ts b/frontend/src/types/__generated__/moduleQueries.generated.ts index 50d5bbc5bc..ad2133551b 100644 --- a/frontend/src/types/__generated__/moduleQueries.generated.ts +++ b/frontend/src/types/__generated__/moduleQueries.generated.ts @@ -22,9 +22,21 @@ export type GetProgramAdminsAndModulesQueryVariables = Types.Exact<{ }>; -export type GetProgramAdminsAndModulesQuery = { getProgram: { __typename: 'ProgramNode', id: string, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null }, getModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, tags: Array | null, projectId: string | null, projectName: string | null, domains: Array | null, experienceLevel: Types.ExperienceLevelEnum, startedAt: unknown, endedAt: unknown, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> } }; +export type GetProgramAdminsAndModulesQuery = { getProgram: { __typename: 'ProgramNode', id: string, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null }, getModule: { __typename: 'ModuleNode', id: string, key: string, name: string, description: string, tags: Array | null, labels: Array | null, projectId: string | null, projectName: string | null, domains: Array | null, experienceLevel: Types.ExperienceLevelEnum, startedAt: unknown, endedAt: unknown, mentors: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> } }; + +export type GetModuleIssuesQueryVariables = Types.Exact<{ + programKey: Types.Scalars['String']['input']; + moduleKey: Types.Scalars['String']['input']; + limit?: Types.InputMaybe; + offset?: Types.InputMaybe; + label?: Types.InputMaybe; +}>; + + +export type GetModuleIssuesQuery = { getModule: { __typename: 'ModuleNode', name: string, issuesCount: number, availableLabels: Array, issues: Array<{ __typename: 'IssueNode', id: string, number: number, title: string, state: string, labels: Array, assignees: Array<{ __typename: 'UserNode', avatarUrl: string, login: string, name: string }> }> } }; export const GetModulesByProgramDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModulesByProgram"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgramModules"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetModuleByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModuleByID"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; -export const GetProgramAdminsAndModulesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProgramAdminsAndModules"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgram"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"admins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"getModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const GetProgramAdminsAndModulesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProgramAdminsAndModules"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgram"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"admins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"getModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"projectName"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetModuleIssuesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModuleIssues"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"20"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"offset"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"label"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModule"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"label"},"value":{"kind":"Variable","name":{"kind":"Name","value":"label"}}}]},{"kind":"Field","name":{"kind":"Name","value":"availableLabels"}},{"kind":"Field","name":{"kind":"Name","value":"issues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}},{"kind":"Argument","name":{"kind":"Name","value":"offset"},"value":{"kind":"Variable","name":{"kind":"Name","value":"offset"}}},{"kind":"Argument","name":{"kind":"Name","value":"label"},"value":{"kind":"Variable","name":{"kind":"Name","value":"label"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/__generated__/programsQueries.generated.ts b/frontend/src/types/__generated__/programsQueries.generated.ts index daa4783da8..bcbe8e9058 100644 --- a/frontend/src/types/__generated__/programsQueries.generated.ts +++ b/frontend/src/types/__generated__/programsQueries.generated.ts @@ -8,7 +8,7 @@ export type GetMyProgramsQueryVariables = Types.Exact<{ }>; -export type GetMyProgramsQuery = { myPrograms: { __typename: 'PaginatedPrograms', currentPage: number, totalPages: number, programs: Array<{ __typename: 'ProgramNode', id: string, key: string, name: string, description: string, startedAt: unknown, endedAt: unknown, userRole: string | null }> } }; +export type GetMyProgramsQuery = { myPrograms: { __typename: 'PaginatedPrograms', currentPage: number, totalPages: number, programs: Array<{ __typename: 'ProgramNode', id: string, key: string, name: string, status: Types.ProgramStatusEnum, description: string, startedAt: unknown, endedAt: unknown, userRole: string | null }> } }; export type GetProgramDetailsQueryVariables = Types.Exact<{ programKey: Types.Scalars['String']['input']; @@ -32,7 +32,7 @@ export type GetProgramAdminDetailsQueryVariables = Types.Exact<{ export type GetProgramAdminDetailsQuery = { getProgram: { __typename: 'ProgramNode', id: string, key: string, name: string, admins: Array<{ __typename: 'MentorNode', id: string, login: string, name: string, avatarUrl: string }> | null } }; -export const GetMyProgramsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMyPrograms"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"page"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myPrograms"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}},{"kind":"Argument","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"page"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentPage"}},{"kind":"Field","name":{"kind":"Name","value":"totalPages"}},{"kind":"Field","name":{"kind":"Name","value":"programs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"userRole"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetMyProgramsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMyPrograms"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"page"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"limit"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myPrograms"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}},{"kind":"Argument","name":{"kind":"Name","value":"page"},"value":{"kind":"Variable","name":{"kind":"Name","value":"page"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"Variable","name":{"kind":"Name","value":"limit"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentPage"}},{"kind":"Field","name":{"kind":"Name","value":"totalPages"}},{"kind":"Field","name":{"kind":"Name","value":"programs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"userRole"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetProgramDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProgramDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgram"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"menteesLimit"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevels"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"admins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetProgramAndModulesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProgramAndModules"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgram"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"menteesLimit"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevels"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"admins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"getProgramModules"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"startedAt"}},{"kind":"Field","name":{"kind":"Name","value":"endedAt"}},{"kind":"Field","name":{"kind":"Name","value":"mentors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetProgramAdminDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProgramAdminDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getProgram"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"admins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/card.ts b/frontend/src/types/card.ts index 8382bb0835..d0fd51b200 100644 --- a/frontend/src/types/card.ts +++ b/frontend/src/types/card.ts @@ -49,6 +49,7 @@ export interface DetailsCardProps { healthMetricsData?: HealthMetricsProps[] heatmap?: JSX.Element isActive?: boolean + labels?: string[] languages?: string[] status?: string setStatus?: (newStatus: string) => void diff --git a/frontend/src/types/issue.ts b/frontend/src/types/issue.ts index bd8f746f0c..25c83f68bf 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,10 +10,13 @@ export type Issue = { organizationName?: string projectName: string projectUrl: string + pullRequests?: PullRequest[] repository?: RepositoryDetails repositoryLanguages?: string[] - summary: string + body?: string title: string + state?: string + summary?: string updatedAt: number url: string objectID: string diff --git a/frontend/src/types/mentorship.ts b/frontend/src/types/mentorship.ts index 7afecf2465..842dca207b 100644 --- a/frontend/src/types/mentorship.ts +++ b/frontend/src/types/mentorship.ts @@ -61,6 +61,7 @@ export type Module = { endedAt: string domains: string[] tags: string[] + labels: string[] } export type ModuleFormData = { @@ -71,6 +72,7 @@ export type ModuleFormData = { endedAt: string domains: string tags: string + labels: string projectName: string projectId: string mentorLogins: string diff --git a/frontend/src/types/pullRequest.ts b/frontend/src/types/pullRequest.ts index 47c15ad798..b0980f3f1b 100644 --- a/frontend/src/types/pullRequest.ts +++ b/frontend/src/types/pullRequest.ts @@ -1,10 +1,13 @@ import type { User } from 'types/user' export type PullRequest = { + id: string author: User createdAt: string organizationName: string repositoryName?: string title: string url: string + state: string + mergedAt?: string }