gabriel / musehub public
milestones.py python
310 lines 11.4 KB
Raw
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago
1 """MuseHub milestones route handlers.
2
3 Endpoint summary:
4 GET /repos/{repo_id}/milestones — list milestones (public)
5 POST /repos/{repo_id}/milestones — create milestone (auth required)
6 GET /repos/{repo_id}/milestones/{number} — get single milestone (public)
7 PATCH /repos/{repo_id}/milestones/{number} — update milestone (auth required)
8 DELETE /repos/{repo_id}/milestones/{number} — delete milestone (auth required)
9
10 Read endpoints use optional_token — unauthenticated access is allowed for public repos.
11 Write endpoints always require a valid MSign token.
12 Deleting a milestone sets milestone_id = NULL on associated issues (not cascade delete).
13 """
14 from __future__ import annotations
15
16
17 import logging
18 from datetime import datetime
19
20 from fastapi import APIRouter, Depends, HTTPException, Query, status
21 from pydantic import Field
22 from sqlalchemy import func, select, update
23 from sqlalchemy.ext.asyncio import AsyncSession
24
25 from musehub.auth.dependencies import TokenClaims, optional_token, require_scope, require_valid_token
26 from musehub.db import get_db
27 from musehub.db import musehub_models as db
28 from musehub.models.musehub import (
29 MilestoneCreate,
30 MilestoneListResponse,
31 MilestoneResponse,
32 )
33 from musehub.models.base import CamelModel
34 from musehub.services import musehub_issues
35 from musehub.services import musehub_repository
36
37 logger = logging.getLogger(__name__)
38
39 router = APIRouter()
40
41
42 # ── Pydantic request models ───────────────────────────────────────────────────
43
44
45 class MilestoneUpdate(CamelModel):
46 """Body for PATCH /repos/{repo_id}/milestones/{number}.
47
48 All fields are optional — send only the fields you want to change.
49 ``state`` must be ``"open"`` or ``"closed"`` if provided.
50 """
51
52 title: str | None = Field(
53 None,
54 min_length=1,
55 max_length=255,
56 description="New milestone title",
57 )
58 description: str | None = Field(None, description="New description (Markdown)")
59 due_on: datetime | None = Field(None, description="New due date (ISO-8601 UTC); null to clear")
60 state: str | None = Field(
61 None,
62 pattern="^(open|closed)$",
63 description="Transition state: 'open' or 'closed'",
64 )
65
66
67 # ── Helpers ───────────────────────────────────────────────────────────────────
68
69
70 def _to_milestone_response(
71 row: db.MusehubMilestone, open_count: int = 0, closed_count: int = 0
72 ) -> MilestoneResponse:
73 """Convert an ORM row to the wire representation."""
74 return MilestoneResponse(
75 milestone_id=row.milestone_id,
76 number=row.number,
77 title=row.title,
78 description=row.description,
79 state=row.state,
80 author=row.author,
81 due_on=row.due_on,
82 open_issues=open_count,
83 closed_issues=closed_count,
84 created_at=row.created_at,
85 )
86
87
88 async def _get_issue_counts(
89 db_session: AsyncSession, milestone_id: str
90 ) -> tuple[int, int]:
91 """Return (open_count, closed_count) of issues linked to a milestone."""
92 open_stmt = select(func.count(db.MusehubIssue.issue_id)).where(
93 db.MusehubIssue.milestone_id == milestone_id,
94 db.MusehubIssue.state == "open",
95 )
96 closed_stmt = select(func.count(db.MusehubIssue.issue_id)).where(
97 db.MusehubIssue.milestone_id == milestone_id,
98 db.MusehubIssue.state == "closed",
99 )
100 open_count: int = (await db_session.execute(open_stmt)).scalar_one()
101 closed_count: int = (await db_session.execute(closed_stmt)).scalar_one()
102 return open_count, closed_count
103
104
105 # ── Endpoints ─────────────────────────────────────────────────────────────────
106
107
108 @router.get(
109 "/repos/{repo_id}/milestones",
110 response_model=MilestoneListResponse,
111 operation_id="listMilestones",
112 summary="List milestones for a MuseHub repo",
113 )
114 async def list_milestones(
115 repo_id: str,
116 state: str = Query(
117 "open",
118 pattern="^(open|closed|all)$",
119 description="Filter by state: 'open', 'closed', or 'all'",
120 ),
121 sort: str = Query(
122 "due_on",
123 pattern="^(due_on|title|completeness)$",
124 description="Sort field: 'due_on', 'title', or 'completeness'",
125 ),
126 db_session: AsyncSession = Depends(get_db),
127 claims: TokenClaims | None = Depends(optional_token),
128 ) -> MilestoneListResponse:
129 """Return milestones for a repo filtered by state.
130
131 Supports filtering by ``state`` (open/closed/all) and sorting by
132 ``due_on``, ``title``, or ``completeness`` (percentage of closed issues).
133 Unauthenticated callers may only access public repos.
134 """
135 repo = await musehub_repository.get_repo(db_session, repo_id)
136 if repo is None:
137 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
138 if repo.visibility != "public" and claims is None:
139 raise HTTPException(
140 status_code=status.HTTP_401_UNAUTHORIZED,
141 detail="Authentication required to access private repos.",
142 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
143 )
144
145 return await musehub_issues.list_milestones(db_session, repo_id, state=state, sort=sort)
146
147
148 @router.post(
149 "/repos/{repo_id}/milestones",
150 response_model=MilestoneResponse,
151 status_code=status.HTTP_201_CREATED,
152 operation_id="createMilestone",
153 summary="Create a milestone for a MuseHub repo",
154 )
155 async def create_milestone(
156 repo_id: str,
157 body: MilestoneCreate,
158 db_session: AsyncSession = Depends(get_db),
159 token: TokenClaims = Depends(require_scope("milestone:write")),
160 ) -> MilestoneResponse:
161 """Create a new milestone in ``open`` state.
162
163 Caller must be the repo owner or a collaborator with write access.
164 The milestone is assigned a sequential per-repo number starting at 1.
165 """
166 repo = await musehub_repository.get_repo(db_session, repo_id)
167 if repo is None:
168 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
169
170 milestone = await musehub_issues.create_milestone(
171 db_session,
172 repo_id=repo_id,
173 title=body.title,
174 description=body.description,
175 author=token.handle,
176 due_on=body.due_on,
177 )
178 await db_session.commit()
179 logger.info("✅ Created milestone for repo %s: %s", repo_id, body.title)
180 return milestone
181
182
183 @router.get(
184 "/repos/{repo_id}/milestones/{number}",
185 response_model=MilestoneResponse,
186 operation_id="getMilestone",
187 summary="Get a single milestone by its per-repo number",
188 )
189 async def get_milestone(
190 repo_id: str,
191 number: int,
192 db_session: AsyncSession = Depends(get_db),
193 claims: TokenClaims | None = Depends(optional_token),
194 ) -> MilestoneResponse:
195 """Return a single milestone with open and closed issue counts.
196
197 The ``number`` is the per-repo sequential milestone number (1-based).
198 Unauthenticated callers may only access public repos.
199 """
200 repo = await musehub_repository.get_repo(db_session, repo_id)
201 if repo is None:
202 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
203 if repo.visibility != "public" and claims is None:
204 raise HTTPException(
205 status_code=status.HTTP_401_UNAUTHORIZED,
206 detail="Authentication required to access private repos.",
207 headers={"WWW-Authenticate": 'MSign realm="musehub"'},
208 )
209
210 milestone = await musehub_issues.get_milestone(db_session, repo_id, number)
211 if milestone is None:
212 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
213 return milestone
214
215
216 @router.patch(
217 "/repos/{repo_id}/milestones/{number}",
218 response_model=MilestoneResponse,
219 operation_id="updateMilestone",
220 summary="Update a milestone's title, description, due date, or state",
221 )
222 async def update_milestone(
223 repo_id: str,
224 number: int,
225 body: MilestoneUpdate,
226 db_session: AsyncSession = Depends(get_db),
227 token: TokenClaims = Depends(require_scope("milestone:write")),
228 ) -> MilestoneResponse:
229 """Partially update a milestone.
230
231 Only provided fields are updated; omitted fields retain their current values.
232 Setting ``state="closed"`` closes the milestone (equivalent to marking it done).
233 Caller must be the repo owner or a collaborator with write access.
234 """
235 repo = await musehub_repository.get_repo(db_session, repo_id)
236 if repo is None:
237 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
238
239 stmt = select(db.MusehubMilestone).where(
240 db.MusehubMilestone.repo_id == repo_id,
241 db.MusehubMilestone.number == number,
242 )
243 row = (await db_session.execute(stmt)).scalar_one_or_none()
244 if row is None:
245 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
246
247 # Apply only provided fields
248 if body.title is not None:
249 row.title = body.title
250 if body.description is not None:
251 row.description = body.description
252 if body.state is not None:
253 row.state = body.state
254 # due_on explicitly supports null (clear) vs. absent (keep current):
255 # We only update if the field was included in the model payload.
256 if "due_on" in body.model_fields_set:
257 row.due_on = body.due_on
258
259 await db_session.flush()
260 await db_session.refresh(row)
261 await db_session.commit()
262
263 open_count, closed_count = await _get_issue_counts(db_session, row.milestone_id)
264 logger.info("✅ Updated milestone #%d for repo %s", number, repo_id)
265 return _to_milestone_response(row, open_count, closed_count)
266
267
268 @router.delete(
269 "/repos/{repo_id}/milestones/{number}",
270 status_code=status.HTTP_204_NO_CONTENT,
271 operation_id="deleteMilestone",
272 summary="Delete a milestone (issues become milestone-less, not deleted)",
273 )
274 async def delete_milestone(
275 repo_id: str,
276 number: int,
277 db_session: AsyncSession = Depends(get_db),
278 token: TokenClaims = Depends(require_scope("milestone:write")),
279 ) -> None:
280 """Delete a milestone.
281
282 All issues currently assigned to this milestone have their ``milestone_id``
283 set to NULL — the issues themselves are NOT deleted. This mirrors GitHub's
284 behavior when a milestone is removed.
285 Caller must be the repo owner or a collaborator with write access.
286 """
287 repo = await musehub_repository.get_repo(db_session, repo_id)
288 if repo is None:
289 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
290
291 stmt = select(db.MusehubMilestone).where(
292 db.MusehubMilestone.repo_id == repo_id,
293 db.MusehubMilestone.number == number,
294 )
295 row = (await db_session.execute(stmt)).scalar_one_or_none()
296 if row is None:
297 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
298
299 milestone_id = row.milestone_id
300
301 # Detach issues from this milestone before deleting (set to NULL, not cascade).
302 unlink_stmt = (
303 update(db.MusehubIssue)
304 .where(db.MusehubIssue.milestone_id == milestone_id)
305 .values(milestone_id=None)
306 )
307 await db_session.execute(unlink_stmt)
308 await db_session.delete(row)
309 await db_session.commit()
310 logger.info("✅ Deleted milestone #%d (%s) from repo %s", number, milestone_id, repo_id)
File History 1 commit
sha256:a10adeeb7a0169cb9900f9806ed7a973047258abb6283724fe55e8eb68ff3f0a init: musehub initial commit Human 171 days ago