diff --git a/backend/apps/github/admin.py b/backend/apps/github/admin.py index cb5560e851..f02599aa86 100644 --- a/backend/apps/github/admin.py +++ b/backend/apps/github/admin.py @@ -5,6 +5,7 @@ from apps.github.models.issue import Issue from apps.github.models.label import Label +from apps.github.models.milestone import Milestone from apps.github.models.organization import Organization from apps.github.models.pull_request import PullRequest from apps.github.models.release import Release @@ -17,6 +18,13 @@ class LabelAdmin(admin.ModelAdmin): search_fields = ("name", "description") +class MilestoneAdmin(admin.ModelAdmin): + search_fields = ( + "body", + "title", + ) + + class PullRequestAdmin(admin.ModelAdmin): autocomplete_fields = ( "assignees", @@ -192,6 +200,7 @@ class UserAdmin(admin.ModelAdmin): admin.site.register(Issue, IssueAdmin) admin.site.register(Label, LabelAdmin) +admin.site.register(Milestone, MilestoneAdmin) admin.site.register(Organization, OrganizationAdmin) admin.site.register(PullRequest, PullRequestAdmin) admin.site.register(Release, ReleaseAdmin) diff --git a/backend/apps/github/common.py b/backend/apps/github/common.py index c86c768db8..8002ccb742 100644 --- a/backend/apps/github/common.py +++ b/backend/apps/github/common.py @@ -10,6 +10,7 @@ from apps.github.models.issue import Issue from apps.github.models.label import Label +from apps.github.models.milestone import Milestone from apps.github.models.organization import Organization from apps.github.models.pull_request import PullRequest from apps.github.models.release import Release @@ -63,6 +64,37 @@ def sync_repository( ) if not repository.is_archived: + # GitHub repository milestones. + kwargs = { + "direction": "desc", + "sort": "updated", + "state": "all", + } + + until = ( + latest_updated_milestone.updated_at + if (latest_updated_milestone := repository.latest_updated_milestone) + else timezone.now() - td(days=30) + ) + + for gh_milestone in gh_repository.get_milestones(**kwargs): + if gh_milestone.updated_at < until: + break + + milestone = Milestone.update_data( + gh_milestone, + author=User.update_data(gh_milestone.creator), + repository=repository, + ) + + # Labels. + milestone.labels.clear() + for gh_milestone_label in gh_milestone.get_labels(): + try: + milestone.labels.add(Label.update_data(gh_milestone_label)) + except UnknownObjectException: + logger.exception("Couldn't get GitHub milestone label %s", milestone.url) + # GitHub repository issues. project_track_issues = repository.project.track_issues if repository.project else True month_ago = timezone.now() - td(days=30) @@ -86,7 +118,21 @@ def sync_repository( break author = User.update_data(gh_issue.user) - issue = Issue.update_data(gh_issue, author=author, repository=repository) + + # Milestone + milestone = None + if gh_issue.milestone: + milestone = Milestone.update_data( + gh_issue.milestone, + author=User.update_data(gh_issue.milestone.creator), + repository=repository, + ) + issue = Issue.update_data( + gh_issue, + author=author, + milestone=milestone, + repository=repository, + ) # Assignees. issue.assignees.clear() @@ -99,7 +145,7 @@ def sync_repository( try: issue.labels.add(Label.update_data(gh_issue_label)) except UnknownObjectException: - logger.info("Couldn't get GitHub issue label %s", issue.url) + logger.exception("Couldn't get GitHub issue label %s", issue.url) else: logger.info("Skipping issues sync for %s", repository.name) @@ -119,8 +165,20 @@ def sync_repository( break author = User.update_data(gh_pull_request.user) + + # Milestone + milestone = None + if gh_pull_request.milestone: + milestone = Milestone.update_data( + gh_pull_request.milestone, + author=User.update_data(gh_pull_request.milestone.creator), + repository=repository, + ) pull_request = PullRequest.update_data( - gh_pull_request, author=author, repository=repository + gh_pull_request, + author=author, + milestone=milestone, + repository=repository, ) # Assignees. @@ -134,7 +192,7 @@ def sync_repository( try: pull_request.labels.add(Label.update_data(gh_pull_request_label)) except UnknownObjectException: - logger.info("Couldn't get GitHub pull request label %s", pull_request.url) + logger.exception("Couldn't get GitHub pull request label %s", pull_request.url) # GitHub repository releases. releases = [] diff --git a/backend/apps/github/graphql/nodes/milestone.py b/backend/apps/github/graphql/nodes/milestone.py new file mode 100644 index 0000000000..7a9d032c06 --- /dev/null +++ b/backend/apps/github/graphql/nodes/milestone.py @@ -0,0 +1,33 @@ +"""Github Milestone Node.""" + +import graphene + +from apps.common.graphql.nodes import BaseNode +from apps.github.models.milestone import Milestone + + +class MilestoneNode(BaseNode): + """Github Milestone Node.""" + + organization_name = graphene.String() + repository_name = graphene.String() + + class Meta: + model = Milestone + + fields = ( + "author", + "created_at", + "title", + "open_issues_count", + "closed_issues_count", + "url", + ) + + def resolve_repository_name(self, info): + """Resolve repository name.""" + return self.repository.name + + def resolve_organization_name(self, info): + """Return organization name.""" + return self.repository.organization.login if self.repository.organization else None diff --git a/backend/apps/github/graphql/nodes/repository.py b/backend/apps/github/graphql/nodes/repository.py index 88b31b1ad7..e9a80fa630 100644 --- a/backend/apps/github/graphql/nodes/repository.py +++ b/backend/apps/github/graphql/nodes/repository.py @@ -4,6 +4,7 @@ from apps.common.graphql.nodes import BaseNode from apps.github.graphql.nodes.issue import IssueNode +from apps.github.graphql.nodes.milestone import MilestoneNode from apps.github.graphql.nodes.release import ReleaseNode from apps.github.graphql.nodes.repository_contributor import RepositoryContributorNode from apps.github.models.repository import Repository @@ -16,6 +17,10 @@ class RepositoryNode(BaseNode): """Repository node.""" issues = graphene.List(IssueNode) + recent_milestones = graphene.List( + MilestoneNode, + limit=graphene.Int(default_value=5), + ) languages = graphene.List(graphene.String) latest_release = graphene.String() owner_key = graphene.String() @@ -69,6 +74,14 @@ def resolve_releases(self, info): "-published_at", )[:RECENT_RELEASES_LIMIT] + def resolve_recent_milestones(self, info, limit=5): + """Resolve recent milestones.""" + return self.recent_milestones.select_related( + "repository", + ).order_by( + "-created_at", + )[:limit] + def resolve_top_contributors(self, info): """Resolve top contributors.""" return self.idx_top_contributors diff --git a/backend/apps/github/graphql/queries/__init__.py b/backend/apps/github/graphql/queries/__init__.py index 328360b0eb..2282fdbc7c 100644 --- a/backend/apps/github/graphql/queries/__init__.py +++ b/backend/apps/github/graphql/queries/__init__.py @@ -1,6 +1,7 @@ """GitHub GraphQL queries.""" from apps.github.graphql.queries.issue import IssueQuery +from apps.github.graphql.queries.milestone import MilestoneQuery from apps.github.graphql.queries.organization import OrganizationQuery from apps.github.graphql.queries.pull_request import PullRequestQuery from apps.github.graphql.queries.release import ReleaseQuery @@ -11,6 +12,7 @@ class GithubQuery( IssueQuery, + MilestoneQuery, OrganizationQuery, PullRequestQuery, ReleaseQuery, diff --git a/backend/apps/github/graphql/queries/milestone.py b/backend/apps/github/graphql/queries/milestone.py new file mode 100644 index 0000000000..a6cb159c1f --- /dev/null +++ b/backend/apps/github/graphql/queries/milestone.py @@ -0,0 +1,91 @@ +"""Github Milestone Queries.""" + +import graphene +from django.core.exceptions import ValidationError +from django.db.models import OuterRef, Subquery + +from apps.common.graphql.queries import BaseQuery +from apps.github.graphql.nodes.milestone import MilestoneNode +from apps.github.models.milestone import Milestone + + +class MilestoneQuery(BaseQuery): + """Github Milestone Queries.""" + + recent_milestones = graphene.List( + MilestoneNode, + distinct=graphene.Boolean(default_value=False), + limit=graphene.Int(default_value=5), + login=graphene.String(required=False), + organization=graphene.String(required=False), + state=graphene.String(default_value="open"), + ) + + def resolve_recent_milestones( + root, + info, + *, + distinct: bool = False, + limit: int = 5, + login: str | None = None, + organization: str | None = None, + state: str = "open", + ): + """Resolve milestones. + + Args: + root (object): The root object. + info (ResolveInfo): The GraphQL execution context. + distinct (bool): Whether to return distinct milestones. + limit (int): The maximum number of milestones to return. + login (str, optional): The GitHub username to filter milestones. + organization (str, optional): The GitHub organization to filter milestones. + state (str, optional): The state of the milestones to return. + + Returns: + list: A list of milestones. + + """ + match state.lower(): + case "open": + milestones = Milestone.open_milestones.all() + case "closed": + milestones = Milestone.closed_milestones.all() + case "all": + milestones = Milestone.objects.all() + case _: + message = f"Invalid state: {state}. Valid states are 'open', 'closed', or 'all'." + raise ValidationError(message) + + milestones = milestones.select_related( + "author", + "repository", + "repository__organization", + ).prefetch_related( + "issues", + "labels", + "pull_requests", + ) + if login: + milestones = milestones.filter( + author__login=login, + ) + + if organization: + milestones = milestones.filter( + repository__organization__login=organization, + ) + + if distinct: + latest_milestone_per_author = ( + milestones.filter(author_id=OuterRef("author_id")) + .order_by("-created_at") + .values("id")[:1] + ) + milestones = milestones.filter( + id__in=Subquery(latest_milestone_per_author), + ).order_by( + "-created_at", + ) + + return milestones[:limit] diff --git a/backend/apps/github/migrations/0024_milestone.py b/backend/apps/github/migrations/0024_milestone.py new file mode 100644 index 0000000000..c07442fe34 --- /dev/null +++ b/backend/apps/github/migrations/0024_milestone.py @@ -0,0 +1,84 @@ +# Generated by Django 5.2 on 2025-04-21 14:45 + +import django.db.models.deletion +from django.db import migrations, models + +import apps.github.models.mixins.issue + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0023_alter_user_contributions_count"), + ] + + operations = [ + migrations.CreateModel( + name="Milestone", + 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)), + ("node_id", models.CharField(unique=True, verbose_name="Node ID")), + ("title", models.CharField(max_length=1000, verbose_name="Title")), + ("body", models.TextField(default="", verbose_name="Body")), + ( + "state", + models.CharField( + choices=[("open", "Open"), ("closed", "Closed")], + default="open", + max_length=6, + verbose_name="State", + ), + ), + ("url", models.URLField(default="", max_length=500, verbose_name="URL")), + ("number", models.PositiveBigIntegerField(default=0, verbose_name="Number")), + ("sequence_id", models.PositiveBigIntegerField(default=0, verbose_name="ID")), + ( + "closed_at", + models.DateTimeField(blank=True, null=True, verbose_name="Closed at"), + ), + ("created_at", models.DateTimeField(verbose_name="Created at")), + ("updated_at", models.DateTimeField(db_index=True, verbose_name="Updated at")), + ("open_issues_count", models.PositiveIntegerField(default=0)), + ("closed_issues_count", models.PositiveIntegerField(default=0)), + ("due_on", models.DateTimeField(blank=True, null=True)), + ( + "author", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="milestones", + to="github.user", + ), + ), + ( + "labels", + models.ManyToManyField( + blank=True, related_name="milestones", to="github.label" + ), + ), + ( + "repository", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="milestones", + to="github.repository", + ), + ), + ], + options={ + "verbose_name_plural": "Milestones", + "db_table": "github_milestone", + "ordering": ["-updated_at", "-state"], + }, + bases=(apps.github.models.mixins.issue.IssueIndexMixin, models.Model), + ), + ] diff --git a/backend/apps/github/migrations/0025_merge_20250424_1217.py b/backend/apps/github/migrations/0025_merge_20250424_1217.py new file mode 100644 index 0000000000..3b80139867 --- /dev/null +++ b/backend/apps/github/migrations/0025_merge_20250424_1217.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2 on 2025-04-24 12:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0024_alter_organization_email_alter_organization_location_and_more"), + ("github", "0024_milestone"), + ] + + operations = [] diff --git a/backend/apps/github/migrations/0026_issue_milestone_pullrequest_milestone.py b/backend/apps/github/migrations/0026_issue_milestone_pullrequest_milestone.py new file mode 100644 index 0000000000..7d8280af71 --- /dev/null +++ b/backend/apps/github/migrations/0026_issue_milestone_pullrequest_milestone.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2 on 2025-04-24 12:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0025_merge_20250424_1217"), + ] + + operations = [ + migrations.AddField( + model_name="issue", + name="milestone", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="issues", + to="github.milestone", + ), + ), + migrations.AddField( + model_name="pullrequest", + name="milestone", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="pull_requests", + to="github.milestone", + ), + ), + ] diff --git a/backend/apps/github/migrations/0027_alter_milestone_table.py b/backend/apps/github/migrations/0027_alter_milestone_table.py new file mode 100644 index 0000000000..0345f4f30d --- /dev/null +++ b/backend/apps/github/migrations/0027_alter_milestone_table.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2 on 2025-05-05 04:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0026_issue_milestone_pullrequest_milestone"), + ] + + operations = [ + migrations.AlterModelTable( + name="milestone", + table="github_milestones", + ), + ] diff --git a/backend/apps/github/migrations/0028_merge_20250510_1542.py b/backend/apps/github/migrations/0028_merge_20250510_1542.py new file mode 100644 index 0000000000..4a6991bd85 --- /dev/null +++ b/backend/apps/github/migrations/0028_merge_20250510_1542.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2 on 2025-05-10 15:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0026_alter_organization_company_and_more"), + ("github", "0027_alter_milestone_table"), + ] + + operations = [] diff --git a/backend/apps/github/models/__init__.py b/backend/apps/github/models/__init__.py index 8a74e83367..7d14cf7128 100644 --- a/backend/apps/github/models/__init__.py +++ b/backend/apps/github/models/__init__.py @@ -1,3 +1,4 @@ """Github app.""" +from .milestone import Milestone from .pull_request import PullRequest diff --git a/backend/apps/github/models/issue.py b/backend/apps/github/models/issue.py index 1d4ca8b9d6..693272a017 100644 --- a/backend/apps/github/models/issue.py +++ b/backend/apps/github/models/issue.py @@ -54,6 +54,13 @@ class Meta: null=True, related_name="created_issues", ) + milestone = models.ForeignKey( + "github.Milestone", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="issues", + ) repository = models.ForeignKey( "github.Repository", on_delete=models.CASCADE, @@ -76,12 +83,13 @@ class Meta: blank=True, ) - def from_github(self, gh_issue, *, author=None, repository=None) -> None: + def from_github(self, gh_issue, *, author=None, milestone=None, repository=None): """Update the instance based on GitHub issue data. Args: gh_issue (github.Issue.Issue): The GitHub issue object. author (User, optional): The author of the issue. + milestone (Milestone, optional): The milestone related to the issue. repository (Repository, optional): The repository instance. """ @@ -110,6 +118,9 @@ def from_github(self, gh_issue, *, author=None, repository=None) -> None: # Author. self.author = author + # Milestone. + self.milestone = milestone + # Repository. self.repository = repository @@ -174,12 +185,13 @@ def open_issues_count(): return IndexBase.get_total_count("issues") @staticmethod - def update_data(gh_issue, *, author=None, repository=None, save: bool = True): + def update_data(gh_issue, *, author=None, milestone=None, repository=None, save: bool = True): """Update issue data. Args: gh_issue (github.Issue.Issue): The GitHub issue object. author (User, optional): The author of the issue. + milestone (Milestone, optional): The milestone related to the issue. repository (Repository, optional): The repository instance. save (bool, optional): Whether to save the instance. @@ -193,7 +205,7 @@ def update_data(gh_issue, *, author=None, repository=None, save: bool = True): except Issue.DoesNotExist: issue = Issue(node_id=issue_node_id) - issue.from_github(gh_issue, author=author, repository=repository) + issue.from_github(gh_issue, author=author, milestone=milestone, repository=repository) if save: issue.save() diff --git a/backend/apps/github/models/managers/milestone.py b/backend/apps/github/models/managers/milestone.py new file mode 100644 index 0000000000..5b838b3dd7 --- /dev/null +++ b/backend/apps/github/models/managers/milestone.py @@ -0,0 +1,19 @@ +"""Github app milestone manager.""" + +from django.db import models + + +class OpenMilestoneManager(models.Manager): + """Open milestone manager.""" + + def get_queryset(self): + """Get open milestones.""" + return super().get_queryset().filter(state="open") + + +class ClosedMilestoneManager(models.Manager): + """Closed milestone manager.""" + + def get_queryset(self): + """Get closed milestones.""" + return super().get_queryset().filter(state="closed") diff --git a/backend/apps/github/models/milestone.py b/backend/apps/github/models/milestone.py new file mode 100644 index 0000000000..ddf24c00b0 --- /dev/null +++ b/backend/apps/github/models/milestone.py @@ -0,0 +1,107 @@ +"""Github app Milestone model.""" + +from django.db import models + +from apps.common.models import BulkSaveModel +from apps.github.models.generic_issue_model import GenericIssueModel +from apps.github.models.managers.milestone import ClosedMilestoneManager, OpenMilestoneManager + + +class Milestone(GenericIssueModel): + """GitHub Milestone model.""" + + objects = models.Manager() + open_milestones = OpenMilestoneManager() + closed_milestones = ClosedMilestoneManager() + + class Meta: + db_table = "github_milestones" + verbose_name_plural = "Milestones" + ordering = ["-updated_at", "-state"] + + open_issues_count = models.PositiveIntegerField(default=0) + closed_issues_count = models.PositiveIntegerField(default=0) + due_on = models.DateTimeField(blank=True, null=True) + + # FKs. + author = models.ForeignKey( + "github.User", + on_delete=models.CASCADE, + related_name="milestones", + blank=True, + null=True, + ) + + repository = models.ForeignKey( + "github.Repository", + on_delete=models.CASCADE, + related_name="milestones", + blank=True, + null=True, + ) + + # M2Ms. + labels = models.ManyToManyField( + "github.Label", + related_name="milestones", + blank=True, + ) + + def from_github(self, gh_milestone, author=None, repository=None) -> None: + """Populate Milestone from GitHub API response. + + Args: + gh_milestone (github.Milestone.Milestone): GitHub milestone object. + author (User, optional): Author of the milestone. Defaults to None. + repository (Repository, optional): Repository of the milestone. Defaults to None. + + """ + field_mapping = { + "body": "description", + "closed_at": "closed_at", + "closed_issues_count": "closed_issues", + "created_at": "created_at", + "due_on": "due_on", + "number": "number", + "open_issues_count": "open_issues", + "state": "state", + "title": "title", + "updated_at": "updated_at", + "url": "html_url", + } + + for model_field, gh_field in field_mapping.items(): + value = getattr(gh_milestone, gh_field) + if value is not None: + setattr(self, model_field, value) + + self.author = author + self.repository = repository + + @staticmethod + def bulk_save(milestones, fields=None): + """Bulk save milestones.""" + BulkSaveModel.bulk_save(Milestone, milestones, fields=fields) + + @staticmethod + def update_data(gh_milestone, author=None, repository=None, save=True): + """Update Milestone data. + + Args: + gh_milestone (github.Milestone.Milestone): GitHub milestone object. + author (User, optional): Author of the issue. Defaults to None. + repository (Repository, optional): Repository of the issue. Defaults to None. + save (bool, optional): Whether to save the instance after updating. Defaults to True. + + """ + milestone_node_id = Milestone.get_node_id(gh_milestone) + try: + milestone = Milestone.objects.get(node_id=milestone_node_id) + except Milestone.DoesNotExist: + milestone = Milestone(node_id=milestone_node_id) + + milestone.from_github(gh_milestone, author, repository) + if save: + milestone.save() + + return milestone diff --git a/backend/apps/github/models/pull_request.py b/backend/apps/github/models/pull_request.py index d2c7bd5c67..84e1deac9e 100644 --- a/backend/apps/github/models/pull_request.py +++ b/backend/apps/github/models/pull_request.py @@ -33,6 +33,13 @@ class Meta: null=True, related_name="created_pull_requests", ) + milestone = models.ForeignKey( + "github.Milestone", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="pull_requests", + ) repository = models.ForeignKey( "github.Repository", on_delete=models.CASCADE, @@ -55,13 +62,14 @@ class Meta: blank=True, ) - def from_github(self, gh_pull_request, *, author=None, repository=None) -> None: + def from_github(self, gh_pull_request, *, author=None, milestone=None, repository=None): """Update the instance based on GitHub pull request data. Args: gh_pull_request (github.PullRequest.PullRequest): The GitHub pull request object. author (User, optional): The author of the pull request. repository (Repository, optional): The repository instance. + milestone (Milestone, optional): The milestone related to the pull request. """ field_mapping = { @@ -86,6 +94,9 @@ def from_github(self, gh_pull_request, *, author=None, repository=None) -> None: # Author. self.author = author + # Milestone. + self.milestone = milestone + # Repository. self.repository = repository @@ -103,6 +114,7 @@ def update_data( gh_pull_request, *, author=None, + milestone=None, repository=None, save: bool = True, ) -> "PullRequest": @@ -111,6 +123,7 @@ def update_data( Args: gh_pull_request (github.PullRequest.PullRequest): The GitHub pull request object. author (User, optional): The author of the pull request. + milestone (Milestone, optional): The milestone related to the pull request. repository (Repository, optional): The repository instance. save (bool, optional): Whether to save the instance. @@ -124,7 +137,9 @@ def update_data( except PullRequest.DoesNotExist: pull_request = PullRequest(node_id=pull_request_node_id) - pull_request.from_github(gh_pull_request, author=author, repository=repository) + pull_request.from_github( + gh_pull_request, author=author, milestone=milestone, repository=repository + ) if save: pull_request.save() diff --git a/backend/apps/github/models/repository.py b/backend/apps/github/models/repository.py index 271ab2698c..34f10966f7 100644 --- a/backend/apps/github/models/repository.py +++ b/backend/apps/github/models/repository.py @@ -11,6 +11,7 @@ from apps.common.models import TimestampedModel from apps.github.constants import OWASP_LOGIN from apps.github.models.common import NodeModel +from apps.github.models.milestone import Milestone from apps.github.models.mixins import RepositoryIndexMixin from apps.github.utils import ( check_funding_policy_compliance, @@ -133,6 +134,11 @@ def latest_updated_issue(self): """Repository latest updated issue.""" return self.issues.order_by("-updated_at").first() + @property + def latest_updated_milestone(self): + """Repository latest updated milestone (most recently modified).""" + return self.milestones.order_by("-updated_at").first() + @property def latest_updated_pull_request(self): """Repository latest updated pull request (most recently modified).""" @@ -162,6 +168,13 @@ def published_releases(self): published_at__isnull=False, ) + @property + def recent_milestones(self): + """Repository recent milestones.""" + return Milestone.objects.filter( + repository=self, + ).order_by("-created_at") + @property def top_languages(self) -> list[str]: """Return a list of top used languages.""" diff --git a/backend/apps/owasp/graphql/nodes/project.py b/backend/apps/owasp/graphql/nodes/project.py index db118028e4..432787168f 100644 --- a/backend/apps/owasp/graphql/nodes/project.py +++ b/backend/apps/owasp/graphql/nodes/project.py @@ -3,6 +3,7 @@ import graphene from apps.github.graphql.nodes.issue import IssueNode +from apps.github.graphql.nodes.milestone import MilestoneNode from apps.github.graphql.nodes.release import ReleaseNode from apps.github.graphql.nodes.repository import RepositoryNode from apps.owasp.graphql.nodes.common import GenericEntityNode @@ -20,6 +21,7 @@ class ProjectNode(GenericEntityNode): languages = graphene.List(graphene.String) level = graphene.String() recent_issues = graphene.List(IssueNode) + recent_milestones = graphene.List(MilestoneNode, limit=graphene.Int(default_value=5)) recent_releases = graphene.List(ReleaseNode) repositories = graphene.List(RepositoryNode) repositories_count = graphene.Int() @@ -57,6 +59,10 @@ def resolve_recent_issues(self, info): """Resolve recent issues.""" return self.issues.select_related("author").order_by("-created_at")[:RECENT_ISSUES_LIMIT] + def resolve_recent_milestones(self, info, limit=5): + """Resolve recent milestones.""" + return self.recent_milestones.select_related("author").order_by("-created_at")[:limit] + def resolve_recent_releases(self, info): """Resolve recent releases.""" return self.published_releases.order_by("-published_at")[:RECENT_RELEASES_LIMIT] diff --git a/backend/apps/owasp/models/event.py b/backend/apps/owasp/models/event.py index a03494b080..7721af638e 100644 --- a/backend/apps/owasp/models/event.py +++ b/backend/apps/owasp/models/event.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from datetime import date from dateutil import parser diff --git a/backend/apps/owasp/models/project.py b/backend/apps/owasp/models/project.py index 6ff6afdac8..9acfbe3f47 100644 --- a/backend/apps/owasp/models/project.py +++ b/backend/apps/owasp/models/project.py @@ -11,6 +11,7 @@ from apps.common.utils import get_absolute_url from apps.core.models.prompt import Prompt from apps.github.models.issue import Issue +from apps.github.models.milestone import Milestone from apps.github.models.release import Release from apps.owasp.models.common import RepositoryBasedEntityModel from apps.owasp.models.managers.project import ActiveProjectManager @@ -182,6 +183,15 @@ def published_releases(self): "repository", ) + @property + def recent_milestones(self): + """Return recent milestones.""" + return Milestone.objects.filter( + repository__in=self.repositories.all(), + ).select_related( + "repository", + ) + def deactivate(self) -> None: """Deactivate project.""" self.is_active = False diff --git a/backend/apps/slack/utils.py b/backend/apps/slack/utils.py index 50865a8fb0..502a542c35 100644 --- a/backend/apps/slack/utils.py +++ b/backend/apps/slack/utils.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING from urllib.parse import urljoin -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from django.db.models import QuerySet import requests diff --git a/backend/tests/apps/github/graphql/nodes/milestone_test.py b/backend/tests/apps/github/graphql/nodes/milestone_test.py new file mode 100644 index 0000000000..36492aebc6 --- /dev/null +++ b/backend/tests/apps/github/graphql/nodes/milestone_test.py @@ -0,0 +1,28 @@ +"""Test cases for MilestoneNode.""" + +from apps.common.graphql.nodes import BaseNode +from apps.github.graphql.nodes.milestone import MilestoneNode +from apps.github.models.milestone import Milestone + + +class TestMilestoneNode: + """Test cases for MilestoneNode class.""" + + def test_milestone_node_inheritance(self): + """Test if IssueNode inherits from BaseNode.""" + assert issubclass(MilestoneNode, BaseNode) + + def test_meta_configuration(self): + """Test if Meta is properly configured.""" + assert MilestoneNode._meta.model == Milestone + expected_fields = { + "author", + "closed_issues_count", + "created_at", + "open_issues_count", + "organization_name", + "repository_name", + "title", + "url", + } + assert set(MilestoneNode._meta.fields) == expected_fields diff --git a/backend/tests/apps/github/graphql/nodes/repository_test.py b/backend/tests/apps/github/graphql/nodes/repository_test.py index f2e86fec11..c25264471d 100644 --- a/backend/tests/apps/github/graphql/nodes/repository_test.py +++ b/backend/tests/apps/github/graphql/nodes/repository_test.py @@ -24,12 +24,13 @@ def test_meta_configuration(self): "issues", "key", "languages", + "latest_release", "license", "name", "open_issues_count", "organization", "owner_key", - "latest_release", + "recent_milestones", "releases", "size", "stars_count", diff --git a/backend/tests/apps/owasp/graphql/nodes/project_test.py b/backend/tests/apps/owasp/graphql/nodes/project_test.py index b2b3f34ae4..14c4b9b119 100644 --- a/backend/tests/apps/owasp/graphql/nodes/project_test.py +++ b/backend/tests/apps/owasp/graphql/nodes/project_test.py @@ -100,6 +100,7 @@ def test_all_fields_exist_in_model(self): "languages", "leaders", "recent_issues", + "recent_milestones", "recent_releases", "repositories_count", "repositories", diff --git a/backend/tests/slack/models/workspace_test.py b/backend/tests/slack/models/workspace_test.py new file mode 100644 index 0000000000..c8516113b0 --- /dev/null +++ b/backend/tests/slack/models/workspace_test.py @@ -0,0 +1,19 @@ +import os +from unittest.mock import patch + +from apps.slack.models.workspace import Workspace + + +class TestWorkspaceModel: + def test_bot_token(self): + workspace_id = "T123ABC" + expected_token = "xoxb-test-token" # noqa: S105 + with patch.dict(os.environ, {f"SLACK_BOT_TOKEN_{workspace_id.upper()}": expected_token}): + workspace = Workspace(slack_workspace_id=workspace_id) + + assert workspace.bot_token == expected_token + + def test_str(self): + workspace = Workspace(name="Test Workspace", slack_workspace_id="test-workspace") + + assert str(workspace) == "Test Workspace" diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index b6d84828e3..655adbb4ad 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -108,6 +108,7 @@ wsgi xapp xdg xdist +xoxb xsser zsc éàëîôû diff --git a/frontend/__tests__/e2e/data/mockHomeData.ts b/frontend/__tests__/e2e/data/mockHomeData.ts index 50adad4fe8..74badaf426 100644 --- a/frontend/__tests__/e2e/data/mockHomeData.ts +++ b/frontend/__tests__/e2e/data/mockHomeData.ts @@ -195,6 +195,22 @@ export const mockHomeData = { __typename: 'ReleaseNode', }, ], + recentMilestones: [ + { + author: { + avatarUrl: 'https://avatars.githubusercontent.com/u/33333?v=4', + login: 'milestone-author1', + name: 'Milestone Author 1', + }, + title: 'v2.0.0 Release', + openIssuesCount: 5, + closedIssuesCount: 15, + repositoryName: 'Home Repo One', + organizationName: 'OWASP', + createdAt: '2025-03-01T10:00:00Z', + url: 'https://github.com/OWASP/repo-one/milestone/1', + }, + ], sponsors: [ { imageUrl: diff --git a/frontend/__tests__/e2e/pages/About.spec.ts b/frontend/__tests__/e2e/pages/About.spec.ts index 39e9497aab..ee265f8651 100644 --- a/frontend/__tests__/e2e/pages/About.spec.ts +++ b/frontend/__tests__/e2e/pages/About.spec.ts @@ -75,15 +75,9 @@ test.describe('About Page', () => { await expect(page.getByText('890+Stars')).toBeVisible() }) - test('opens user profile in new window when leader button is clicked', async ({ - page, - context, - }) => { - const pagePromise = context.waitForEvent('page') + test('opens user profile in new window when leader button is clicked', async ({ page }) => { await page.getByRole('button', { name: 'View Profile' }).first().click() - const newPage = await pagePromise - await newPage.waitForLoadState() - expect(newPage.url()).toContain('/members/') + await expect(page).toHaveURL('/members/arkid15r') }) test('breadcrumb renders correct segments on /about', async ({ page }) => { diff --git a/frontend/__tests__/e2e/pages/Home.spec.ts b/frontend/__tests__/e2e/pages/Home.spec.ts index cf74b543ab..82f43853aa 100644 --- a/frontend/__tests__/e2e/pages/Home.spec.ts +++ b/frontend/__tests__/e2e/pages/Home.spec.ts @@ -76,6 +76,13 @@ test.describe('Home Page', () => { await expect(page.getByText('repo-1')).toBeVisible() }) + test('should have recent milestones', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Recent Milestones' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'v2.0.0 Release' })).toBeVisible() + await expect(page.getByText('Mar 1, 2025')).toBeVisible() + await expect(page.getByText('Home Repo One')).toBeVisible() + }) + test('should be able to join OWASP', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Ready to Make a Difference?' })).toBeVisible() await expect(page.getByText('Join OWASP and be part of the')).toBeVisible() diff --git a/frontend/__tests__/e2e/pages/OrganizationDetails.spec.ts b/frontend/__tests__/e2e/pages/OrganizationDetails.spec.ts index 4cc2414e4c..fd908b855b 100644 --- a/frontend/__tests__/e2e/pages/OrganizationDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/OrganizationDetails.spec.ts @@ -44,6 +44,13 @@ test.describe('Organization Details Page', () => { await expect(page.getByText('Test Issue 2')).toBeVisible() }) + test('should have organization recent milestones', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Recent Milestones' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'v2.0.0 Release' })).toBeVisible() + await expect(page.getByText('Mar 1, 2025')).toBeVisible() + await expect(page.getByText('Project Repo 1')).toBeVisible() + }) + test('should display recent releases section', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Recent Releases' })).toBeVisible() await expect(page.getByRole('link', { name: 'Release v2.0.0' }).first()).toBeVisible() diff --git a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts index 27c63eaa54..1402bf453e 100644 --- a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts @@ -88,6 +88,13 @@ test.describe('Project Details Page', () => { await expect(page.getByText('Jan 20, 2025')).toBeVisible() }) + test('should have project recent milestones', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Recent Milestones' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'v2.0.0 Release' })).toBeVisible() + await expect(page.getByText('Mar 1, 2025')).toBeVisible() + await expect(page.getByText('Project Repo 1')).toBeVisible() + }) + test('should display recent pull requests section', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Recent Pull Requests' })).toBeVisible() await expect(page.getByText('Test Pull Request 1')).toBeVisible() diff --git a/frontend/__tests__/e2e/pages/RepositoryDetails.spec.ts b/frontend/__tests__/e2e/pages/RepositoryDetails.spec.ts index 0a648d56f3..407c132e56 100644 --- a/frontend/__tests__/e2e/pages/RepositoryDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/RepositoryDetails.spec.ts @@ -84,6 +84,13 @@ test.describe('Repository Details Page', () => { await expect(page.getByText('Jan 1, 2024', { exact: true })).toBeVisible() }) + test('should have recent milestones', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Recent Milestones' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'v2.0.0 Release' })).toBeVisible() + await expect(page.getByText('Mar 1, 2025')).toBeVisible() + await expect(page.getByText('Repo One')).toBeVisible() + }) + test('should display recent pull requests section', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Recent Pull Requests' })).toBeVisible() await expect(page.getByText('Test Pull Request 1')).toBeVisible() diff --git a/frontend/__tests__/e2e/pages/UserDetails.spec.ts b/frontend/__tests__/e2e/pages/UserDetails.spec.ts index 71b3c6b753..738fca7cff 100644 --- a/frontend/__tests__/e2e/pages/UserDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/UserDetails.spec.ts @@ -49,6 +49,13 @@ test.describe('User Details Page', () => { await expect(page.getByText('v1.0.0')).toBeVisible() }) + test('should have user recent milestones', async ({ page }) => { + await expect(page.getByRole('heading', { name: 'Recent Milestones' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'v2.0.0 Release' })).toBeVisible() + await expect(page.getByText('Mar 1, 2025')).toBeVisible() + await expect(page.getByText('Project Repo 1')).toBeVisible() + }) + test('should have user pull requests', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Pull Requests' })).toBeVisible() await expect(page.getByText('Test Pull Request')).toBeVisible() diff --git a/frontend/__tests__/unit/data/mockHomeData.ts b/frontend/__tests__/unit/data/mockHomeData.ts index be4369f7a3..0536578223 100644 --- a/frontend/__tests__/unit/data/mockHomeData.ts +++ b/frontend/__tests__/unit/data/mockHomeData.ts @@ -100,6 +100,22 @@ export const mockGraphQLData = { url: 'https://github.com/owasp/owasp-nest/releases/tag/v0.9.2', }, ], + recentMilestones: [ + { + author: { + avatarUrl: 'https://avatars.githubusercontent.com/u/33333?v=4', + login: 'milestone-author1', + name: 'Milestone Author 1', + }, + title: 'v2.0.0 Release', + openIssuesCount: 5, + closedIssuesCount: 15, + repositoryName: 'Home Repo One', + organizationName: 'OWASP', + createdAt: '2025-03-01T10:00:00Z', + url: 'https://github.com/OWASP/repo-one/milestone/1', + }, + ], statsOverview: { activeChaptersStats: 540, activeProjectsStats: 95, diff --git a/frontend/__tests__/unit/data/mockOrganizationData.ts b/frontend/__tests__/unit/data/mockOrganizationData.ts index e294758405..7ce5122f06 100644 --- a/frontend/__tests__/unit/data/mockOrganizationData.ts +++ b/frontend/__tests__/unit/data/mockOrganizationData.ts @@ -161,4 +161,20 @@ export const mockOrganizationDetailsData = { }, }, ], + recentMilestones: [ + { + author: { + avatarUrl: 'https://avatars.githubusercontent.com/u/33333?v=4', + login: 'milestone-author1', + name: 'Milestone Author 1', + }, + title: 'v2.0.0 Release', + openIssuesCount: 5, + closedIssuesCount: 15, + repositoryName: 'Project Repo 1', + organizationName: 'OWASP', + createdAt: '2025-03-01T10:00:00Z', + url: 'https://github.com/OWASP/repo-one/milestone/1', + }, + ], } diff --git a/frontend/__tests__/unit/data/mockProjectDetailsData.ts b/frontend/__tests__/unit/data/mockProjectDetailsData.ts index 0751ffcfb5..3e3b3ef2d2 100644 --- a/frontend/__tests__/unit/data/mockProjectDetailsData.ts +++ b/frontend/__tests__/unit/data/mockProjectDetailsData.ts @@ -70,6 +70,22 @@ export const mockProjectDetailsData = { type: 'Tool', updatedAt: '2025-02-07T12:34:56Z', url: 'https://github.com/example-project', + recentMilestones: [ + { + author: { + avatarUrl: 'https://avatars.githubusercontent.com/u/33333?v=4', + login: 'milestone-author1', + name: 'Milestone Author 1', + }, + title: 'v2.0.0 Release', + openIssuesCount: 5, + closedIssuesCount: 15, + repositoryName: 'Project Repo 1', + organizationName: 'OWASP', + createdAt: '2025-03-01T10:00:00Z', + url: 'https://github.com/OWASP/repo-one/milestone/1', + }, + ], }, recentPullRequests: [ { diff --git a/frontend/__tests__/unit/data/mockRepositoryData.ts b/frontend/__tests__/unit/data/mockRepositoryData.ts index c18207f632..685ac0ac89 100644 --- a/frontend/__tests__/unit/data/mockRepositoryData.ts +++ b/frontend/__tests__/unit/data/mockRepositoryData.ts @@ -39,6 +39,22 @@ export const mockRepositoryData = { }, }, ], + recentMilestones: [ + { + author: { + avatarUrl: 'https://avatars.githubusercontent.com/u/33333?v=4', + login: 'milestone-author1', + name: 'Milestone Author 1', + }, + title: 'v2.0.0 Release', + openIssuesCount: 5, + closedIssuesCount: 15, + repositoryName: 'Repo One', + organizationName: 'OWASP', + createdAt: '2025-03-01T10:00:00Z', + url: 'https://github.com/OWASP/repo-one/milestone/1', + }, + ], }, recentPullRequests: [ { diff --git a/frontend/__tests__/unit/data/mockUserDetails.ts b/frontend/__tests__/unit/data/mockUserDetails.ts index a673a50ada..a082f47170 100644 --- a/frontend/__tests__/unit/data/mockUserDetails.ts +++ b/frontend/__tests__/unit/data/mockUserDetails.ts @@ -22,6 +22,17 @@ export const mockUserDetailsData = { url: 'https://github.com/OWASP/Nest/issues/798', }, ], + recentMilestones: [ + { + title: 'v2.0.0 Release', + openIssuesCount: 5, + closedIssuesCount: 15, + repositoryName: 'Project Repo 1', + organizationName: 'OWASP', + createdAt: '2025-03-01T10:00:00Z', + url: 'https://github.com/OWASP/repo-one/milestone/1', + }, + ], recentReleases: [ { isPreRelease: false, diff --git a/frontend/__tests__/unit/pages/global-error.test.tsx b/frontend/__tests__/unit/global-error.test.tsx similarity index 100% rename from frontend/__tests__/unit/pages/global-error.test.tsx rename to frontend/__tests__/unit/global-error.test.tsx diff --git a/frontend/__tests__/unit/pages/About.test.tsx b/frontend/__tests__/unit/pages/About.test.tsx index 1cca14ebf6..2c29c54268 100644 --- a/frontend/__tests__/unit/pages/About.test.tsx +++ b/frontend/__tests__/unit/pages/About.test.tsx @@ -400,6 +400,18 @@ describe('About Component', () => { }) }) + test('navigates to user details on View Profile button click', async () => { + render() + + await waitFor(() => { + const viewDetailsButtons = screen.getAllByText('View Profile') + expect(viewDetailsButtons[0]).toBeInTheDocument() + fireEvent.click(viewDetailsButtons[0]) + }) + + expect(mockRouter.push).toHaveBeenCalledWith('/members/arkid15r') + }) + test('handles partial user data in leader response', async () => { const partialUserData = { data: { diff --git a/frontend/__tests__/unit/pages/Home.test.tsx b/frontend/__tests__/unit/pages/Home.test.tsx index 385b3872e0..77ab474314 100644 --- a/frontend/__tests__/unit/pages/Home.test.tsx +++ b/frontend/__tests__/unit/pages/Home.test.tsx @@ -230,6 +230,19 @@ describe('Home', () => { }) }) + test('renders milestones section correctly', async () => { + render() + await waitFor(() => { + const recentMilestones = mockGraphQLData.recentMilestones + + recentMilestones.forEach((milestone) => { + expect(screen.getByText(milestone.title)).toBeInTheDocument() + expect(screen.getByText(milestone.repositoryName)).toBeInTheDocument() + expect(screen.getByText(`${milestone.openIssuesCount} open`)).toBeInTheDocument() + expect(screen.getByText(`${milestone.closedIssuesCount} closed`)).toBeInTheDocument() + }) + }) + }) test('renders when no recent releases', async () => { ;(useQuery as jest.Mock).mockReturnValue({ data: { diff --git a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx index d089958ccf..f6f2072e07 100644 --- a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx +++ b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx @@ -111,6 +111,56 @@ describe('OrganizationDetailsPage', () => { }) }) + test('renders milestones section correctly', async () => { + render() + await waitFor(() => { + const recentMilestones = mockOrganizationDetailsData.recentMilestones + + recentMilestones.forEach((milestone) => { + expect(screen.getByText(milestone.title)).toBeInTheDocument() + expect(screen.getByText(milestone.repositoryName)).toBeInTheDocument() + expect(screen.getByText(`${milestone.openIssuesCount} open`)).toBeInTheDocument() + expect(screen.getByText(`${milestone.closedIssuesCount} closed`)).toBeInTheDocument() + }) + }) + }) + + test('handles no recent releases gracefully', async () => { + const noReleasesData = { + ...mockOrganizationDetailsData, + recentReleases: [], + } + ;(useQuery as jest.Mock).mockReturnValue({ + data: noReleasesData, + loading: false, + error: null, + }) + render() + await waitFor(() => { + expect(screen.getByText('Recent Releases')).toBeInTheDocument() + expect(screen.queryByText('Test v1.0.0')).not.toBeInTheDocument() + }) + }) + + test('renders no milestones correctly', async () => { + const noMilestones = { + ...mockOrganizationDetailsData, + recentMilestones: [], + } + + ;(useQuery as jest.Mock).mockReturnValue({ + data: noMilestones, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + expect(screen.getByText('Recent Milestones')).toBeInTheDocument() + expect(screen.queryByText('v2.0.0 Release')).not.toBeInTheDocument() + }) + }) + test('renders pull requests section correctly', async () => { render() diff --git a/frontend/__tests__/unit/pages/ProjectDetails.test.tsx b/frontend/__tests__/unit/pages/ProjectDetails.test.tsx index 4485b889ac..1a191420ee 100644 --- a/frontend/__tests__/unit/pages/ProjectDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ProjectDetails.test.tsx @@ -222,6 +222,19 @@ describe('ProjectDetailsPage', () => { }) }) + test('renders milestones section correctly', async () => { + render() + await waitFor(() => { + const recentMilestones = mockProjectDetailsData.project.recentMilestones + + recentMilestones.forEach((milestone) => { + expect(screen.getByText(milestone.title)).toBeInTheDocument() + expect(screen.getByText(milestone.repositoryName)).toBeInTheDocument() + expect(screen.getByText(`${milestone.openIssuesCount} open`)).toBeInTheDocument() + expect(screen.getByText(`${milestone.closedIssuesCount} closed`)).toBeInTheDocument() + }) + }) + }) test('renders project stats correctly', async () => { ;(useQuery as jest.Mock).mockReturnValue({ data: mockProjectDetailsData, diff --git a/frontend/__tests__/unit/pages/RepositoryDetails.test.tsx b/frontend/__tests__/unit/pages/RepositoryDetails.test.tsx index 783cb62dd0..4ab52dab27 100644 --- a/frontend/__tests__/unit/pages/RepositoryDetails.test.tsx +++ b/frontend/__tests__/unit/pages/RepositoryDetails.test.tsx @@ -179,6 +179,21 @@ describe('RepositoryDetailsPage', () => { }) }) + test('renders milestones section correctly', async () => { + render() + await waitFor(() => { + const recentMilestones = mockRepositoryData.repository.recentMilestones + + expect(screen.getByText('Recent Milestones')).toBeInTheDocument() + recentMilestones.forEach((milestone) => { + expect(screen.getByText(milestone.title)).toBeInTheDocument() + expect(screen.getByText(milestone.repositoryName)).toBeInTheDocument() + expect(screen.getByText(`${milestone.openIssuesCount} open`)).toBeInTheDocument() + expect(screen.getByText(`${milestone.closedIssuesCount} closed`)).toBeInTheDocument() + }) + }) + }) + test('handles missing repository stats gracefully', async () => { ;(useQuery as jest.Mock).mockReturnValue({ data: { diff --git a/frontend/__tests__/unit/pages/UserDetails.test.tsx b/frontend/__tests__/unit/pages/UserDetails.test.tsx index 8c47669d50..894dc25af7 100644 --- a/frontend/__tests__/unit/pages/UserDetails.test.tsx +++ b/frontend/__tests__/unit/pages/UserDetails.test.tsx @@ -93,6 +93,10 @@ describe('UserDetailsPage', () => { expect(screen.getByText('Contribution Heatmap')).toBeInTheDocument() expect(screen.getByText('Test Company')).toBeInTheDocument() expect(screen.getByText('Test Location')).toBeInTheDocument() + expect(screen.getByText('10 Followers')).toBeInTheDocument() + expect(screen.getByText('5 Followings')).toBeInTheDocument() + expect(screen.getByText('3 Repositories')).toBeInTheDocument() + expect(screen.getByText('100 Contributions')).toBeInTheDocument() }) test('renders recent issues correctly', async () => { @@ -134,6 +138,46 @@ describe('UserDetailsPage', () => { }) }) + test('renders recent releases correctly', async () => { + ;(useQuery as jest.Mock).mockReturnValue({ + data: mockUserDetailsData, + error: null, + loading: false, + }) + render() + await waitFor(() => { + const releasesTitle = screen.getByText('Recent Releases') + expect(releasesTitle).toBeInTheDocument() + const releases = mockUserDetailsData.recentReleases + releases.forEach((release) => { + expect(screen.getByText(release.name)).toBeInTheDocument() + expect(screen.getByText(release.repositoryName)).toBeInTheDocument() + }) + }) + }) + + test('renders recent milestones correctly', async () => { + ;(useQuery as jest.Mock).mockReturnValue({ + data: mockUserDetailsData, + error: null, + loading: false, + }) + + render() + + await waitFor(() => { + const milestonesTitle = screen.getByText('Recent Milestones') + expect(milestonesTitle).toBeInTheDocument() + const milestones = mockUserDetailsData.recentMilestones + milestones.forEach((milestone) => { + expect(screen.getByText(milestone.title)).toBeInTheDocument() + expect(screen.getByText(milestone.repositoryName)).toBeInTheDocument() + expect(screen.getByText(`${milestone.openIssuesCount} open`)).toBeInTheDocument() + expect(screen.getByText(`${milestone.closedIssuesCount} closed`)).toBeInTheDocument() + }) + }) + }) + test('renders repositories section correctly', async () => { ;(useQuery as jest.Mock).mockReturnValue({ data: mockUserDetailsData, @@ -199,6 +243,21 @@ describe('UserDetailsPage', () => { }) }) + test('handles contribution heatmap loading error correctly', async () => { + ;(useQuery as jest.Mock).mockReturnValue({ + data: mockUserDetailsData, + error: null, + }) + ;(fetchHeatmapData as jest.Mock).mockResolvedValue(null) + + render() + + await waitFor(() => { + const heatmapTitle = screen.queryByText('Contribution Heatmap') + expect(heatmapTitle).not.toBeInTheDocument() + }) + }) + test('renders user summary section correctly', async () => { ;(useQuery as jest.Mock).mockReturnValue({ data: mockUserDetailsData, @@ -328,6 +387,40 @@ describe('UserDetailsPage', () => { }) }) + test('handles no recent releases gracefully', async () => { + const noReleasesData = { + ...mockUserDetailsData, + recentReleases: [], + } + ;(useQuery as jest.Mock).mockReturnValue({ + data: noReleasesData, + loading: false, + error: null, + }) + render() + await waitFor(() => { + expect(screen.getByText('Recent Releases')).toBeInTheDocument() + expect(screen.queryByText('Test v1.0.0')).not.toBeInTheDocument() + }) + }) + + test('handles no recent milestones gracefully', async () => { + const noMilestonesData = { + ...mockUserDetailsData, + recentMilestones: [], + } + ;(useQuery as jest.Mock).mockReturnValue({ + data: noMilestonesData, + loading: false, + error: null, + }) + render() + await waitFor(() => { + expect(screen.getByText('Recent Milestones')).toBeInTheDocument() + expect(screen.queryByText('v2.0.0 Release')).not.toBeInTheDocument() + }) + }) + test('renders statistics with zero values correctly', async () => { const zeroStatsData = { ...mockUserDetailsData, diff --git a/frontend/src/app/about/page.tsx b/frontend/src/app/about/page.tsx index c2ead04947..43546009c5 100644 --- a/frontend/src/app/about/page.tsx +++ b/frontend/src/app/about/page.tsx @@ -10,10 +10,12 @@ import { import { addToast } from '@heroui/toast' import Image from 'next/image' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { GET_PROJECT_DATA } from 'server/queries/projectQueries' import { GET_LEADER_DATA } from 'server/queries/userQueries' import { ProjectTypeGraphql } from 'types/project' +import { User } from 'types/user' import { aboutText, roadmap, technologies } from 'utils/aboutData' import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper' import AnchorTitle from 'components/AnchorTitle' @@ -42,7 +44,7 @@ const About = () => { useEffect(() => { if (data) { - setProject(data?.project) + setProject(data.project) setIsLoading(false) } if (graphQLRequestError) { @@ -181,6 +183,7 @@ const LeaderData = ({ username }: { username: string }) => { const { data, loading, error } = useQuery(GET_LEADER_DATA, { variables: { key: username }, }) + const router = useRouter() if (loading) return

Loading {username}...

if (error) return

Error loading {username}'s data

@@ -191,13 +194,17 @@ const LeaderData = ({ username }: { username: string }) => { return

No data available for {username}

} + const handleButtonClick = (user: User) => { + router.push(`/members/${user.login}`) + } + return ( , label: 'View Profile', - onclick: () => window.open(`/members/${username}`, '_blank', 'noopener,noreferrer'), + onclick: () => handleButtonClick(user), }} className="h-64 w-40 bg-inherit" company={user.company} diff --git a/frontend/src/app/chapters/[chapterKey]/page.tsx b/frontend/src/app/chapters/[chapterKey]/page.tsx index 8e0ea0c386..65d5cb6e94 100644 --- a/frontend/src/app/chapters/[chapterKey]/page.tsx +++ b/frontend/src/app/chapters/[chapterKey]/page.tsx @@ -23,8 +23,8 @@ export default function ChapterDetailsPage() { useEffect(() => { if (data) { - setChapter(data?.chapter) - setTopContributors(data?.topContributors) + setChapter(data.chapter) + setTopContributors(data.topContributors) setIsLoading(false) } if (graphQLRequestError) { diff --git a/frontend/src/app/committees/[committeeKey]/page.tsx b/frontend/src/app/committees/[committeeKey]/page.tsx index b5ceaf600e..72098bef3e 100644 --- a/frontend/src/app/committees/[committeeKey]/page.tsx +++ b/frontend/src/app/committees/[committeeKey]/page.tsx @@ -30,7 +30,7 @@ export default function CommitteeDetailsPage() { useEffect(() => { if (data?.committee) { setCommittee(data.committee) - setTopContributors(data?.topContributors) + setTopContributors(data.topContributors) setIsLoading(false) } if (graphQLRequestError) { diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx index f0dac9d2f2..565590b8ba 100644 --- a/frontend/src/app/members/[memberKey]/page.tsx +++ b/frontend/src/app/members/[memberKey]/page.tsx @@ -11,7 +11,12 @@ import Link from 'next/link' import { useParams } from 'next/navigation' import React, { useState, useEffect, useRef, useMemo } from 'react' import { GET_USER_DATA } from 'server/queries/userQueries' -import type { ProjectIssuesType, ProjectReleaseType, RepositoryCardProps } from 'types/project' +import type { + ProjectIssuesType, + ProjectMilestonesType, + ProjectReleaseType, + RepositoryCardProps, +} from 'types/project' import type { ItemCardPullRequests, PullRequestsType, UserDetailsProps } from 'types/user' import { formatDate } from 'utils/dateFormatter' import { drawContributions, fetchHeatmapData, HeatmapData } from 'utils/helpers/githubHeatmap' @@ -24,13 +29,14 @@ const UserDetailsPage: React.FC = () => { const [user, setUser] = useState() const [issues, setIssues] = useState([]) const [topRepositories, setTopRepositories] = useState([]) + const [milestones, setMilestones] = useState([]) const [pullRequests, setPullRequests] = useState([]) const [releases, setReleases] = useState([]) const [data, setData] = useState({} as HeatmapData) const [isLoading, setIsLoading] = useState(true) const [username, setUsername] = useState('') const [imageLink, setImageLink] = useState('') - const [privateContributor, setPrivateContributor] = useState(false) + const [isPrivateContributor, setIsPrivateContributor] = useState(false) const canvasRef = useRef(null) const theme = 'blue' @@ -40,11 +46,12 @@ const UserDetailsPage: React.FC = () => { useEffect(() => { if (graphQLData) { - setUser(graphQLData?.user) - setIssues(graphQLData?.recentIssues) - setPullRequests(graphQLData?.recentPullRequests) - setReleases(graphQLData?.recentReleases) - setTopRepositories(graphQLData?.topContributedRepositories) + setUser(graphQLData.user) + setIssues(graphQLData.recentIssues) + setMilestones(graphQLData.recentMilestones) + setPullRequests(graphQLData.recentPullRequests) + setReleases(graphQLData.recentReleases) + setTopRepositories(graphQLData.topContributedRepositories) setIsLoading(false) } if (graphQLRequestError) { @@ -55,15 +62,14 @@ const UserDetailsPage: React.FC = () => { useEffect(() => { const fetchData = async () => { - if (!memberKey) { + const result = await fetchHeatmapData(memberKey as string) + if (!result) { + setIsPrivateContributor(true) return } - const result = await fetchHeatmapData(memberKey as string) - if (typeof result !== 'string' && result.contributions) { + if (result?.contributions) { setUsername(memberKey as string) setData(result as HeatmapData) - } else { - setPrivateContributor(true) } } fetchData() @@ -157,6 +163,26 @@ const UserDetailsPage: React.FC = () => { ) }, [releases, user]) + const formattedMilestones: ProjectMilestonesType[] = useMemo(() => { + return ( + milestones?.map((milestone) => ({ + author: { + avatarUrl: user?.avatarUrl || '', + key: user?.login || '', + login: user?.login || '', + name: user?.name || user?.login || '', + }, + createdAt: milestone.createdAt, + openIssuesCount: milestone.openIssuesCount, + closedIssuesCount: milestone.closedIssuesCount, + organizationName: milestone.organizationName, + repositoryName: milestone.repositoryName, + title: milestone.title, + url: milestone.url, + })) || [] + ) + }, [milestones, user]) + if (isLoading) { return } @@ -244,8 +270,9 @@ const UserDetailsPage: React.FC = () => { } + heatmap={isPrivateContributor ? undefined : } details={userDetails} + recentMilestones={formattedMilestones} pullRequests={formattedPullRequest} stats={userStats} type="user" diff --git a/frontend/src/app/members/page.tsx b/frontend/src/app/members/page.tsx index 8b443b329e..751669e288 100644 --- a/frontend/src/app/members/page.tsx +++ b/frontend/src/app/members/page.tsx @@ -38,7 +38,7 @@ const UsersPage = () => { { const { organizationKey } = useParams() const [organization, setOrganization] = useState(null) const [issues, setIssues] = useState(null) + const [milestones, setMilestones] = useState(null) const [pullRequests, setPullRequests] = useState(null) const [releases, setReleases] = useState(null) const [repositories, setRepositories] = useState(null) @@ -30,12 +31,13 @@ const OrganizationDetailsPage = () => { useEffect(() => { if (graphQLData) { - setOrganization(graphQLData?.organization) - setIssues(graphQLData?.recentIssues) - setPullRequests(graphQLData?.recentPullRequests) - setReleases(graphQLData?.recentReleases) - setRepositories(graphQLData?.repositories) - setTopContributors(graphQLData?.topContributors) + setMilestones(graphQLData.recentMilestones) + setOrganization(graphQLData.organization) + setIssues(graphQLData.recentIssues) + setPullRequests(graphQLData.recentPullRequests) + setReleases(graphQLData.recentReleases) + setRepositories(graphQLData.repositories) + setTopContributors(graphQLData.topContributors) setIsLoading(false) } if (graphQLRequestError) { @@ -68,7 +70,7 @@ const OrganizationDetailsPage = () => { ), }, { - label: 'Joined', + label: 'Created', value: formatDate(organization.createdAt), }, { @@ -77,7 +79,7 @@ const OrganizationDetailsPage = () => { }, { label: 'Location', - value: organization.location || 'Not provided', + value: organization.location, }, ] @@ -115,6 +117,7 @@ const OrganizationDetailsPage = () => { details={organizationDetails} recentIssues={issues} recentReleases={releases} + recentMilestones={milestones} pullRequests={pullRequests} repositories={repositories} stats={organizationStats} diff --git a/frontend/src/app/organizations/[organizationKey]/repositories/[repositoryKey]/page.tsx b/frontend/src/app/organizations/[organizationKey]/repositories/[repositoryKey]/page.tsx index 1d01790173..8e7c509446 100644 --- a/frontend/src/app/organizations/[organizationKey]/repositories/[repositoryKey]/page.tsx +++ b/frontend/src/app/organizations/[organizationKey]/repositories/[repositoryKey]/page.tsx @@ -29,9 +29,9 @@ const RepositoryDetailsPage = () => { }) useEffect(() => { if (data) { - setRepository(data?.repository) - setTopContributors(data?.topContributors) - setRecentPullRequests(data?.recentPullRequests) + setRepository(data.repository) + setTopContributors(data.topContributors) + setRecentPullRequests(data.recentPullRequests) setIsLoading(false) } if (graphQLRequestError) { @@ -117,6 +117,7 @@ const RepositoryDetailsPage = () => { title={repository.name} topContributors={topContributors} topics={repository.topics} + recentMilestones={repository.recentMilestones} type="repository" /> ) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 37521abedd..a21da167fd 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -33,6 +33,7 @@ import ChapterMapWrapper from 'components/ChapterMapWrapper' import LeadersList from 'components/LeadersList' import LoadingSpinner from 'components/LoadingSpinner' import MovingLogos from 'components/LogoCarousel' +import Milestones from 'components/Milestones' import DialogComp from 'components/Modal' import MultiSearchBar from 'components/MultiSearch' import RecentIssues from 'components/RecentIssues' @@ -111,19 +112,19 @@ export default function Home() { const counterData = [ { label: 'Active Projects', - value: data?.statsOverview.activeProjectsStats.toString().concat('+'), + value: data.statsOverview.activeProjectsStats.toString().concat('+'), }, { label: 'Contributors', - value: data?.statsOverview.contributorsStats.toString().concat('+'), + value: data.statsOverview.contributorsStats.toString().concat('+'), }, { label: 'Local Chapters', - value: data?.statsOverview.activeChaptersStats.toString().concat('+'), + value: data.statsOverview.activeChaptersStats.toString().concat('+'), }, { label: 'Countries', - value: data?.statsOverview.countriesStats.toString().concat('+'), + value: data.statsOverview.countriesStats.toString().concat('+'), }, ] @@ -141,7 +142,7 @@ export default function Home() {
- {data?.upcomingEvents?.map((event: EventType, index: number) => ( + {data.upcomingEvents.map((event: EventType, index: number) => (
+
+ )} +
+ )} + /> + ) +} + +export default Milestones diff --git a/frontend/src/server/queries/homeQueries.ts b/frontend/src/server/queries/homeQueries.ts index c96071823b..13811760f7 100644 --- a/frontend/src/server/queries/homeQueries.ts +++ b/frontend/src/server/queries/homeQueries.ts @@ -56,7 +56,7 @@ export const GET_MAIN_PAGE_DATA = gql` title url } - recentReleases(limit: 9, distinct: $distinct) { + recentReleases(limit: 5, distinct: $distinct) { author { avatarUrl login @@ -91,5 +91,19 @@ export const GET_MAIN_PAGE_DATA = gql` suggestedLocation url } + recentMilestones(limit: 5, state: "all", distinct: $distinct) { + author { + avatarUrl + login + name + } + title + openIssuesCount + closedIssuesCount + repositoryName + organizationName + createdAt + url + } } ` diff --git a/frontend/src/server/queries/organizationQueries.ts b/frontend/src/server/queries/organizationQueries.ts index fefccf2c21..5a5657c37c 100644 --- a/frontend/src/server/queries/organizationQueries.ts +++ b/frontend/src/server/queries/organizationQueries.ts @@ -41,7 +41,7 @@ export const GET_ORGANIZATION_DATA = gql` title url } - recentReleases(limit: 9, organization: $login, distinct: true) { + recentReleases(limit: 5, organization: $login, distinct: true) { author { avatarUrl login @@ -54,6 +54,20 @@ export const GET_ORGANIZATION_DATA = gql` tagName url } + recentMilestones(limit: 5, organization: $login, distinct: true) { + author { + avatarUrl + login + name + } + title + openIssuesCount + closedIssuesCount + repositoryName + organizationName + createdAt + url + } repositories(organization: $login, limit: 12) { contributorsCount forksCount diff --git a/frontend/src/server/queries/projectQueries.ts b/frontend/src/server/queries/projectQueries.ts index 8d0c580709..e70c870e27 100644 --- a/frontend/src/server/queries/projectQueries.ts +++ b/frontend/src/server/queries/projectQueries.ts @@ -58,6 +58,20 @@ export const GET_PROJECT_DATA = gql` type updatedAt url + recentMilestones(limit: 5) { + author { + avatarUrl + login + name + } + title + openIssuesCount + closedIssuesCount + repositoryName + organizationName + createdAt + url + } } recentPullRequests(project: $key) { author { diff --git a/frontend/src/server/queries/repositoryQueries.ts b/frontend/src/server/queries/repositoryQueries.ts index d569b2efc8..0f7a0afe95 100644 --- a/frontend/src/server/queries/repositoryQueries.ts +++ b/frontend/src/server/queries/repositoryQueries.ts @@ -45,6 +45,20 @@ export const GET_REPOSITORY_DATA = gql` topics updatedAt url + recentMilestones(limit: 5) { + author { + avatarUrl + login + name + } + title + openIssuesCount + closedIssuesCount + repositoryName + organizationName + createdAt + url + } } topContributors(organization: $organizationKey, repository: $repositoryKey) { avatarUrl diff --git a/frontend/src/server/queries/userQueries.ts b/frontend/src/server/queries/userQueries.ts index c7e694a32c..7782a51c14 100644 --- a/frontend/src/server/queries/userQueries.ts +++ b/frontend/src/server/queries/userQueries.ts @@ -19,6 +19,15 @@ export const GET_USER_DATA = gql` title url } + recentMilestones(limit: 5, login: $key) { + title + openIssuesCount + closedIssuesCount + repositoryName + organizationName + createdAt + url + } recentPullRequests(limit: 5, login: $key) { createdAt organizationName @@ -26,7 +35,7 @@ export const GET_USER_DATA = gql` title url } - recentReleases(limit: 6, login: $key) { + recentReleases(limit: 5, login: $key) { isPreRelease name publishedAt diff --git a/frontend/src/types/card.ts b/frontend/src/types/card.ts index a4c7c3e02f..04adf47afe 100644 --- a/frontend/src/types/card.ts +++ b/frontend/src/types/card.ts @@ -5,7 +5,12 @@ import { GeoLocDataGraphQL } from './chapter' import { TopContributorsTypeAlgolia, TopContributorsTypeGraphql } from './contributor' import { IconType } from './icon' import { Level } from './level' -import { ProjectIssuesType, ProjectReleaseType, RepositoryCardProps } from './project' +import { + ProjectIssuesType, + ProjectReleaseType, + RepositoryCardProps, + ProjectMilestonesType, +} from './project' import { ItemCardPullRequests } from './user' export interface CardProps { @@ -39,6 +44,7 @@ export interface DetailsCardProps { pullRequests?: ItemCardPullRequests[] recentIssues?: ProjectIssuesType[] recentReleases?: ProjectReleaseType[] + recentMilestones?: ProjectMilestonesType[] repositories?: RepositoryCardProps[] socialLinks?: string[] stats?: stats[] diff --git a/frontend/src/types/home.ts b/frontend/src/types/home.ts index 0429163e91..e61aabb026 100644 --- a/frontend/src/types/home.ts +++ b/frontend/src/types/home.ts @@ -1,6 +1,6 @@ import { TopContributorsTypeGraphql } from './contributor' import { EventType } from './event' -import { ProjectIssuesType, ProjectReleaseType } from './project' +import { ProjectIssuesType, ProjectReleaseType, ProjectMilestonesType } from './project' export type MainPageData = { topContributors: TopContributorsTypeGraphql[] @@ -8,6 +8,7 @@ export type MainPageData = { recentReleases: ProjectReleaseType[] upcomingEvents: EventType[] recentPullRequests: PullRequestsType[] + recentMilestones: ProjectMilestonesType[] recentChapters: { createdAt: string key: string diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts index cb17114f1f..d50d83a5d8 100644 --- a/frontend/src/types/project.ts +++ b/frontend/src/types/project.ts @@ -29,6 +29,22 @@ export interface ProjectPullRequestsType { url: string } +export interface ProjectMilestonesType { + author: { + avatarUrl: string + key: string + name: string + login: string + } + title: string + openIssuesCount: number + closedIssuesCount: number + repositoryName: string + organizationName?: string + createdAt: string + url: string +} + export interface ProjectStatsType { contributors: number forks: number @@ -82,6 +98,7 @@ export interface ProjectTypeGraphql { recentReleases: ProjectReleaseType[] repositories: RepositoryCardProps[] topContributors: TopContributorsTypeGraphql[] + recentMilestones: ProjectMilestonesType[] } export interface RepositoriesCardProps { diff --git a/frontend/src/utils/dateFormatter.ts b/frontend/src/utils/dateFormatter.ts index 77439e57ba..4688e575ba 100644 --- a/frontend/src/utils/dateFormatter.ts +++ b/frontend/src/utils/dateFormatter.ts @@ -1,4 +1,8 @@ export const formatDate = (input: number | string) => { + if (!input) { + return '' + } + const date = typeof input === 'number' ? new Date(input * 1000) // Unix timestamp in seconds diff --git a/frontend/src/utils/helpers/githubHeatmap.ts b/frontend/src/utils/helpers/githubHeatmap.ts index 71748fa1b2..d33cfd3c75 100644 --- a/frontend/src/utils/helpers/githubHeatmap.ts +++ b/frontend/src/utils/helpers/githubHeatmap.ts @@ -37,10 +37,14 @@ interface HeatmapResponse { contributions: HeatmapContribution[] } -export const fetchHeatmapData = async (username: string): Promise => { +export const fetchHeatmapData = async (username: string): Promise => { try { const response = await fetch(`https://github-contributions-api.jogruber.de/v4/${username}`) - const heatmapData: { contributions: { date: string; count: number }[] } = await response.json() + const heatmapData = await response.json() + if (heatmapData.error) { + return null + } + if (!heatmapData.contributions) { return { years: [], @@ -79,8 +83,8 @@ export const fetchHeatmapData = async (username: string): Promise