diff --git a/backend/apps/api/rest/v0/__init__.py b/backend/apps/api/rest/v0/__init__.py index 236487d5ab..ec43b059db 100644 --- a/backend/apps/api/rest/v0/__init__.py +++ b/backend/apps/api/rest/v0/__init__.py @@ -11,6 +11,7 @@ from apps.api.rest.v0.event import router as event_router from apps.api.rest.v0.issue import router as issue_router from apps.api.rest.v0.member import router as member_router +from apps.api.rest.v0.milestone import router as milestone_router from apps.api.rest.v0.organization import router as organization_router from apps.api.rest.v0.project import router as project_router from apps.api.rest.v0.release import router as release_router @@ -23,6 +24,7 @@ "/events": event_router, "/issues": issue_router, "/members": member_router, + "/milestones": milestone_router, "/organizations": organization_router, "/projects": project_router, "/releases": release_router, diff --git a/backend/apps/api/rest/v0/milestone.py b/backend/apps/api/rest/v0/milestone.py new file mode 100644 index 0000000000..17ec54c197 --- /dev/null +++ b/backend/apps/api/rest/v0/milestone.py @@ -0,0 +1,124 @@ +"""Milestone API.""" + +from datetime import datetime +from http import HTTPStatus +from typing import Literal + +from django.http import HttpRequest +from ninja import Field, FilterSchema, Path, Query, Schema +from ninja.decorators import decorate_view +from ninja.pagination import RouterPaginated +from ninja.responses import Response + +from apps.api.decorators.cache import cache_response +from apps.github.models.generic_issue_model import GenericIssueModel +from apps.github.models.milestone import Milestone as MilestoneModel + +router = RouterPaginated(tags=["Milestones"]) + + +class MilestoneBase(Schema): + """Base schema for Milestone (used in list endpoints).""" + + created_at: datetime + number: int + state: GenericIssueModel.State + title: str + updated_at: datetime + url: str + + +class Milestone(MilestoneBase): + """Schema for Milestone (minimal fields for list display).""" + + +class MilestoneDetail(MilestoneBase): + """Detail schema for Milestone (used in single item endpoints).""" + + body: str + closed_issues_count: int + due_on: datetime | None + open_issues_count: int + + +class MilestoneError(Schema): + """Milestone error schema.""" + + message: str + + +class MilestoneFilter(FilterSchema): + """Filter for Milestone.""" + + organization: str | None = Field( + None, + description="Organization that milestones belong to (filtered by repository owner)", + example="OWASP", + ) + repository: str | None = Field( + None, + description="Repository that milestones belong to", + example="Nest", + ) + state: GenericIssueModel.State | None = Field( + None, + description="Milestone state", + ) + + +@router.get( + "/", + description="Retrieve a paginated list of GitHub milestones.", + operation_id="list_milestones", + response=list[Milestone], + summary="List milestones", +) +@decorate_view(cache_response()) +def list_milestones( + request: HttpRequest, + filters: MilestoneFilter = Query(...), + ordering: Literal["created_at", "-created_at", "updated_at", "-updated_at"] | None = None, +) -> list[Milestone]: + """Get all milestones.""" + milestones = MilestoneModel.objects.select_related("repository", "repository__organization") + + if filters.organization: + milestones = milestones.filter( + repository__organization__login__iexact=filters.organization + ) + if filters.repository: + milestones = milestones.filter(repository__name__iexact=filters.repository) + if filters.state: + milestones = milestones.filter(state=filters.state) + + return milestones.order_by(ordering or "-created_at", "-updated_at") + + +@router.get( + "/{str:organization_id}/{str:repository_id}/{int:milestone_id}", + description=( + "Retrieve a specific GitHub milestone by organization, repository, and milestone number." + ), + operation_id="get_milestone", + response={ + HTTPStatus.NOT_FOUND: MilestoneError, + HTTPStatus.OK: MilestoneDetail, + }, + summary="Get milestone", +) +@decorate_view(cache_response()) +def get_milestone( + request: HttpRequest, + organization_id: str = Path(example="OWASP"), + repository_id: str = Path(example="Nest"), + milestone_id: int = Path(example=1), +) -> MilestoneDetail | MilestoneError: + """Get milestone.""" + try: + return MilestoneModel.objects.get( + repository__organization__login__iexact=organization_id, + repository__name__iexact=repository_id, + number=milestone_id, + ) + except MilestoneModel.DoesNotExist: + return Response({"message": "Milestone not found"}, status=HTTPStatus.NOT_FOUND) diff --git a/backend/tests/apps/api/rest/v0/milestone_test.py b/backend/tests/apps/api/rest/v0/milestone_test.py new file mode 100644 index 0000000000..30819756b1 --- /dev/null +++ b/backend/tests/apps/api/rest/v0/milestone_test.py @@ -0,0 +1,89 @@ +from datetime import datetime + +import pytest + +from apps.api.rest.v0.milestone import MilestoneDetail + + +class TestMilestoneSchema: + @pytest.mark.parametrize( + "milestone_data", + [ + { + "body": "This is a test milestone for Q1", + "closed_issues_count": 5, + "created_at": "2024-01-01T00:00:00Z", + "due_on": "2024-03-31T23:59:59Z", + "number": 1, + "open_issues_count": 3, + "state": "open", + "title": "Q1 2024 Release", + "updated_at": "2024-01-15T00:00:00Z", + "url": "https://github.com/OWASP/Nest/milestone/1", + }, + { + "body": "Completed milestone for Q4 2023", + "closed_issues_count": 15, + "created_at": "2023-10-01T00:00:00Z", + "due_on": "2023-12-31T23:59:59Z", + "number": 2, + "open_issues_count": 0, + "state": "closed", + "title": "Q4 2023 Release", + "updated_at": "2024-01-05T00:00:00Z", + "url": "https://github.com/OWASP/Nest/milestone/2", + }, + { + "body": "Milestone without due date", + "closed_issues_count": 2, + "created_at": "2024-02-01T00:00:00Z", + "due_on": None, + "number": 3, + "open_issues_count": 8, + "state": "open", + "title": "Backlog", + "updated_at": "2024-02-15T00:00:00Z", + "url": "https://github.com/OWASP/Nest/milestone/3", + }, + ], + ) + def test_milestone_schema(self, milestone_data): + milestone = MilestoneDetail(**milestone_data) + + assert milestone.body == milestone_data["body"] + assert milestone.closed_issues_count == milestone_data["closed_issues_count"] + assert milestone.created_at == datetime.fromisoformat(milestone_data["created_at"]) + if milestone_data["due_on"]: + assert milestone.due_on == datetime.fromisoformat(milestone_data["due_on"]) + else: + assert milestone.due_on is None + assert milestone.number == milestone_data["number"] + assert milestone.open_issues_count == milestone_data["open_issues_count"] + assert milestone.state == milestone_data["state"] + assert milestone.title == milestone_data["title"] + assert milestone.updated_at == datetime.fromisoformat(milestone_data["updated_at"]) + assert milestone.url == milestone_data["url"] + + def test_milestone_schema_with_minimal_data(self): + """Test milestone schema with minimal required fields.""" + minimal_data = { + "body": "", + "closed_issues_count": 0, + "created_at": "2024-01-01T00:00:00Z", + "due_on": None, + "number": 1, + "open_issues_count": 0, + "state": "open", + "title": "Test Milestone", + "updated_at": "2024-01-01T00:00:00Z", + "url": "https://github.com/test/repo/milestone/1", + } + milestone = MilestoneDetail(**minimal_data) + + assert milestone.body == "" + assert milestone.closed_issues_count == 0 + assert milestone.due_on is None + assert milestone.number == 1 + assert milestone.open_issues_count == 0 + assert milestone.title == "Test Milestone" + assert milestone.state == "open" diff --git a/backend/tests/apps/api/rest/v0/urls_test.py b/backend/tests/apps/api/rest/v0/urls_test.py index d9ef7159b8..2b67b567dc 100644 --- a/backend/tests/apps/api/rest/v0/urls_test.py +++ b/backend/tests/apps/api/rest/v0/urls_test.py @@ -7,6 +7,7 @@ from apps.api.rest.v0.event import router as event_router from apps.api.rest.v0.issue import router as issue_router from apps.api.rest.v0.member import router as member_router +from apps.api.rest.v0.milestone import router as milestone_router from apps.api.rest.v0.organization import router as organization_router from apps.api.rest.v0.project import router as project_router from apps.api.rest.v0.release import router as release_router @@ -24,6 +25,7 @@ class TestRouterRegistration: "/events": event_router, "/issues": issue_router, "/members": member_router, + "/milestones": milestone_router, "/organizations": organization_router, "/projects": project_router, "/releases": release_router,