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(
Loading {username}...
if (error) returnError loading {username}'s data
@@ -191,13 +194,17 @@ const LeaderData = ({ username }: { username: string }) => { returnNo data available for {username}
} + const handleButtonClick = (user: User) => { + router.push(`/members/${user.login}`) + } + return (