gabriel / musehub public
labels.py python
448 lines 17.3 KB
Raw
sha256:7281683f5c41e5d88b6d8811fbdafebd3e01a0c9dcd90975cfcb444ba71e8e81 docs: add local source-of-truth for musehub#225, #226, #227… Sonnet 5 1 day ago
1 """MuseHub label management route handlers.
2
3 Endpoint summary:
4 GET /repos/{repo_id}/labels — list labels (public)
5 POST /repos/{repo_id}/labels — create label (label:write)
6 PATCH /repos/{repo_id}/labels/{label_id} — update label (label:write)
7 DELETE /repos/{repo_id}/labels/{label_id} — delete label (label:write)
8 POST /repos/{repo_id}/proposals/{proposal_id}/labels — assign labels to proposal (label:write)
9 DELETE /repos/{repo_id}/proposals/{proposal_id}/labels/{label_id} — remove label from proposal (label:write)
10
11 Note: issue label assignment is handled by issues.py (label by name, scope label:write).
12
13 Read endpoints use optional_token — unauthenticated access is allowed for public repos.
14 Write endpoints use require_scope("label:write") — human identities (scope=None) pass
15 unconditionally; agent identities require the label:write capability token.
16
17 ORM dependency: musehub.db.musehub_label_models (batch-01).
18 If that module is not yet merged, mypy will report a missing import — this is
19 expected and resolves once the batch-01 migration merges.
20 """
21
22 import logging
23
24 from fastapi import APIRouter, Depends, HTTPException, status
25 from pydantic import BaseModel, Field, ValidationInfo, field_validator
26 from sqlalchemy import text
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from musehub.auth.dependencies import TokenClaims, optional_token, require_scope
30 from musehub.core.genesis import compute_label_id
31 from musehub.db import get_db
32 from musehub.types.json_types import LabelDef
33 from musehub.services import musehub_repository
34
35 logger = logging.getLogger(__name__)
36
37 router = APIRouter()
38
39 # ── Default labels seeded on repo creation ────────────────────────────────────
40
41 DEFAULT_LABELS: list[LabelDef] = [
42 {"name": "bug", "color": "#d73a4a", "description": "Something isn't working"},
43 {"name": "enhancement", "color": "#a2eeef", "description": "New feature or request"},
44 {"name": "question", "color": "#d876e3", "description": "Further information is requested"},
45 {"name": "documentation", "color": "#0075ca", "description": "Improvements or additions to documentation"},
46 {"name": "good first issue", "color": "#7057ff", "description": "Good for newcomers"},
47 {"name": "help wanted", "color": "#008672", "description": "Extra attention is needed"},
48 {"name": "merge-conflict", "color": "#b60205", "description": "Has conflicting changes that must be resolved"},
49 {"name": "analysis", "color": "#1d76db", "description": "Requires deeper analysis or review"},
50 ]
51
52 # ── Pydantic request / response models ───────────────────────────────────────
53
54 class LabelCreate(BaseModel):
55 """Payload for creating a new label."""
56
57 name: str = Field(..., min_length=1, max_length=50, description="Label name (unique within repo)")
58 color: str = Field(
59 ...,
60 pattern=r"^#[0-9a-fA-F]{6}$",
61 description="Hex colour string, e.g. '#d73a4a'",
62 )
63 description: str | None = Field(None, max_length=200, description="Optional human-readable description")
64
65 class LabelUpdate(BaseModel):
66 """Payload for updating an existing label (all fields optional)."""
67
68 name: str | None = Field(None, min_length=1, max_length=50)
69 color: str | None = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
70 description: str | None = Field(None, max_length=200)
71
72 class LabelResponse(BaseModel):
73 """Public representation of a label."""
74
75 label_id: str
76 repo_id: str
77 name: str
78 color: str
79 description: str | None = None
80
81 model_config = {"from_attributes": True}
82
83 @field_validator("label_id", "repo_id")
84 @classmethod
85 def _check_genesis_ids(cls, v: str, info: ValidationInfo) -> str:
86 from musehub.models.musehub import _check_genesis_id
87 return _check_genesis_id(getattr(info, "field_name", "id"), v)
88
89 class LabelListResponse(BaseModel):
90 """Paginated list of labels."""
91
92 items: list[LabelResponse]
93 total: int
94
95 class AssignLabelsRequest(BaseModel):
96 """Body for bulk-assigning labels to an issue or proposal."""
97
98 label_ids: list[str] = Field(..., min_length=1, description="Array of label IDs to assign")
99
100 # ── Helpers ───────────────────────────────────────────────────────────────────
101
102 async def _guard_repo_owner(db: AsyncSession, repo_id: str, caller_handle: str) -> None:
103 """Raise 403 if the caller is not the repo owner or an accepted write/admin collaborator."""
104 repo = await musehub_repository.get_repo(db, repo_id)
105 if repo is None:
106 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
107 has_access = await musehub_repository.check_write_access(db, repo_id, caller_handle, repo.owner)
108 if not has_access:
109 raise HTTPException(
110 status_code=status.HTTP_403_FORBIDDEN,
111 detail="Only the repo owner or a write/admin collaborator may manage labels.",
112 )
113
114 async def _get_label_or_404(db: AsyncSession, repo_id: str, label_id: str) -> LabelResponse:
115 """Fetch a single label by ID, raising 404 if not found.
116
117 Uses a raw SQL query so this file compiles cleanly before the ORM model
118 (batch-01) is merged into dev.
119 """
120 result = await db.execute(
121 text(
122 "SELECT id AS label_id, repo_id, name, color, description "
123 "FROM musehub_labels "
124 "WHERE id = :label_id AND repo_id = :repo_id"
125 ),
126 {"label_id": label_id, "repo_id": repo_id},
127 )
128 row = result.mappings().one_or_none()
129 if row is None:
130 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Label not found")
131 return LabelResponse(**dict(row))
132
133 # ── Label CRUD ────────────────────────────────────────────────────────────────
134
135 @router.get(
136 "/repos/{repo_id}/labels",
137 response_model=LabelListResponse,
138 operation_id="listLabels",
139 summary="List all labels for a MuseHub repo",
140 )
141 async def list_labels(
142 repo_id: str,
143 db: AsyncSession = Depends(get_db),
144 _claims: TokenClaims | None = Depends(optional_token),
145 ) -> LabelListResponse:
146 """Return every label defined in *repo_id*.
147
148 This endpoint is publicly accessible — no authentication required.
149 """
150 repo = await musehub_repository.get_repo(db, repo_id)
151 if repo is None:
152 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repo not found")
153
154 result = await db.execute(
155 text(
156 "SELECT id AS label_id, repo_id, name, color, description "
157 "FROM musehub_labels "
158 "WHERE repo_id = :repo_id "
159 "ORDER BY name ASC"
160 ),
161 {"repo_id": repo_id},
162 )
163 rows = result.mappings().all()
164 items = [LabelResponse(**dict(r)) for r in rows]
165 return LabelListResponse(items=items, total=len(items))
166
167 @router.post(
168 "/repos/{repo_id}/labels",
169 response_model=LabelResponse,
170 status_code=status.HTTP_201_CREATED,
171 operation_id="createLabel",
172 summary="Create a label in a MuseHub repo",
173 )
174 async def create_label(
175 repo_id: str,
176 body: LabelCreate,
177 db: AsyncSession = Depends(get_db),
178 token: TokenClaims = Depends(require_scope("label:write")),
179 ) -> LabelResponse:
180 """Create a new label with a name, hex colour, and optional description.
181
182 The caller must be authenticated. Names must be unique within the repo.
183 """
184 await _guard_repo_owner(db, repo_id, token.handle)
185
186 # Enforce name uniqueness within the repo.
187 existing = await db.execute(
188 text(
189 "SELECT 1 FROM musehub_labels "
190 "WHERE repo_id = :repo_id AND name = :name"
191 ),
192 {"repo_id": repo_id, "name": body.name},
193 )
194 if existing.scalar_one_or_none() is not None:
195 raise HTTPException(
196 status_code=status.HTTP_409_CONFLICT,
197 detail=f"Label '{body.name}' already exists in this repo",
198 )
199
200 from datetime import datetime, timezone
201 now = datetime.now(timezone.utc)
202 label_id = compute_label_id(repo_id, body.name, now.isoformat())
203 await db.execute(
204 text(
205 "INSERT INTO musehub_labels (id, repo_id, name, color, description, created_at) "
206 "VALUES (:label_id, :repo_id, :name, :color, :description, :created_at)"
207 ),
208 {
209 "label_id": label_id,
210 "repo_id": repo_id,
211 "name": body.name,
212 "color": body.color,
213 "description": body.description,
214 "created_at": now,
215 },
216 )
217 await db.commit()
218 logger.info("✅ Created label '%s' (%s) in repo %s", body.name, label_id, repo_id)
219 return LabelResponse(
220 label_id=label_id,
221 repo_id=repo_id,
222 name=body.name,
223 color=body.color,
224 description=body.description,
225 )
226
227 @router.patch(
228 "/repos/{repo_id}/labels/{label_id}",
229 response_model=LabelResponse,
230 operation_id="updateLabel",
231 summary="Update a label's name, colour, or description",
232 )
233 async def update_label(
234 repo_id: str,
235 label_id: str,
236 body: LabelUpdate,
237 db: AsyncSession = Depends(get_db),
238 token: TokenClaims = Depends(require_scope("label:write")),
239 ) -> LabelResponse:
240 """Partially update an existing label.
241
242 Only fields present in the request body are modified; omitted fields are
243 left unchanged. The caller must be authenticated.
244 """
245 await _guard_repo_owner(db, repo_id, token.handle)
246 label = await _get_label_or_404(db, repo_id, label_id)
247
248 new_name = body.name if body.name is not None else label.name
249 new_color = body.color if body.color is not None else label.color
250 new_description = body.description if body.description is not None else label.description
251
252 # If the name is changing, check uniqueness.
253 if body.name is not None and body.name != label.name:
254 existing = await db.execute(
255 text(
256 "SELECT 1 FROM musehub_labels "
257 "WHERE repo_id = :repo_id AND name = :name AND id != :label_id"
258 ),
259 {"repo_id": repo_id, "name": body.name, "label_id": label_id},
260 )
261 if existing.scalar_one_or_none() is not None:
262 raise HTTPException(
263 status_code=status.HTTP_409_CONFLICT,
264 detail=f"Label '{body.name}' already exists in this repo",
265 )
266
267 await db.execute(
268 text(
269 "UPDATE musehub_labels "
270 "SET name = :name, color = :color, description = :description "
271 "WHERE id = :label_id AND repo_id = :repo_id"
272 ),
273 {
274 "name": new_name,
275 "color": new_color,
276 "description": new_description,
277 "label_id": label_id,
278 "repo_id": repo_id,
279 },
280 )
281 await db.commit()
282 logger.info("✅ Updated label %s in repo %s", label_id, repo_id)
283 return LabelResponse(
284 label_id=label_id,
285 repo_id=repo_id,
286 name=new_name,
287 color=new_color,
288 description=new_description,
289 )
290
291 @router.delete(
292 "/repos/{repo_id}/labels/{label_id}",
293 status_code=status.HTTP_204_NO_CONTENT,
294 operation_id="deleteLabel",
295 summary="Delete a label from a MuseHub repo",
296 )
297 async def delete_label(
298 repo_id: str,
299 label_id: str,
300 db: AsyncSession = Depends(get_db),
301 token: TokenClaims = Depends(require_scope("label:write")),
302 ) -> None:
303 """Permanently delete a label and remove it from all associated issues and proposals.
304
305 The caller must be authenticated.
306 """
307 await _guard_repo_owner(db, repo_id, token.handle)
308 await _get_label_or_404(db, repo_id, label_id)
309
310 # Remove proposal associations before deleting the label row.
311 await db.execute(
312 text("DELETE FROM musehub_proposal_labels WHERE label_id = :label_id"),
313 {"label_id": label_id},
314 )
315 await db.execute(
316 text("DELETE FROM musehub_labels WHERE id = :label_id AND repo_id = :repo_id"),
317 {"label_id": label_id, "repo_id": repo_id},
318 )
319 await db.commit()
320 logger.info("✅ Deleted label %s from repo %s", label_id, repo_id)
321
322 # ── Proposal label associations ──────────────────────────────────────────
323
324 @router.post(
325 "/repos/{repo_id}/proposals/{proposal_id}/labels",
326 response_model=list[LabelResponse],
327 status_code=status.HTTP_200_OK,
328 operation_id="assignLabelsToProposal",
329 summary="Assign one or more labels to a proposal",
330 )
331 async def assign_labels_to_proposal(
332 repo_id: str,
333 proposal_id: str,
334 body: AssignLabelsRequest,
335 db: AsyncSession = Depends(get_db),
336 token: TokenClaims = Depends(require_scope("label:write")),
337 ) -> list[LabelResponse]:
338 """Assign a set of labels (by ID) to a proposal identified by *proposal_id*.
339
340 Labels already assigned are silently ignored (idempotent). The caller must
341 be the repo owner or an accepted write/admin collaborator.
342 """
343 await _guard_repo_owner(db, repo_id, token.handle)
344 proposal_result = await db.execute(
345 text(
346 "SELECT proposal_id FROM musehub_proposals "
347 "WHERE proposal_id = :proposal_id AND repo_id = :repo_id"
348 ),
349 {"proposal_id": proposal_id, "repo_id": repo_id},
350 )
351 existing_proposal_id: str | None = proposal_result.scalar_one_or_none()
352 if existing_proposal_id is None:
353 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found")
354
355 assigned: list[LabelResponse] = []
356 for label_id in body.label_ids:
357 label = await _get_label_or_404(db, repo_id, label_id)
358 await db.execute(
359 text(
360 "INSERT INTO musehub_proposal_labels (proposal_id, label_id) "
361 "VALUES (:proposal_id, :label_id) "
362 "ON CONFLICT DO NOTHING"
363 ),
364 {"proposal_id": proposal_id, "label_id": label_id},
365 )
366 assigned.append(label)
367
368 await db.commit()
369 logger.info("✅ Assigned %d label(s) to proposal %s in repo %s", len(assigned), proposal_id, repo_id)
370 return assigned
371
372 @router.delete(
373 "/repos/{repo_id}/proposals/{proposal_id}/labels/{label_id}",
374 status_code=status.HTTP_204_NO_CONTENT,
375 operation_id="removeLabelFromProposal",
376 summary="Remove a label from a proposal",
377 )
378 async def remove_label_from_proposal(
379 repo_id: str,
380 proposal_id: str,
381 label_id: str,
382 db: AsyncSession = Depends(get_db),
383 token: TokenClaims = Depends(require_scope("label:write")),
384 ) -> None:
385 """Remove a single label association from a proposal.
386
387 Returns 204 whether or not the label was previously assigned, making
388 this endpoint safely idempotent. The caller must be the repo owner or an
389 accepted write/admin collaborator.
390 """
391 await _guard_repo_owner(db, repo_id, token.handle)
392 proposal_result = await db.execute(
393 text(
394 "SELECT proposal_id FROM musehub_proposals "
395 "WHERE proposal_id = :proposal_id AND repo_id = :repo_id"
396 ),
397 {"proposal_id": proposal_id, "repo_id": repo_id},
398 )
399 existing_proposal_id: str | None = proposal_result.scalar_one_or_none()
400 if existing_proposal_id is None:
401 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found")
402
403 await db.execute(
404 text(
405 "DELETE FROM musehub_proposal_labels "
406 "WHERE proposal_id = :proposal_id AND label_id = :label_id"
407 ),
408 {"proposal_id": proposal_id, "label_id": label_id},
409 )
410 await db.commit()
411 logger.info("✅ Removed label %s from proposal %s in repo %s", label_id, proposal_id, repo_id)
412
413 # ── Utility: seed default labels for a new repo ───────────────────────────────
414
415 async def seed_default_labels(db: AsyncSession, repo_id: str) -> None:
416 """Insert the standard set of default labels for a newly created repo.
417
418 Called by the repo-creation service after the repo row is committed.
419 Skips any label whose name already exists in the repo (safe to call
420 multiple times).
421 """
422 for label_def in DEFAULT_LABELS:
423 existing = await db.execute(
424 text(
425 "SELECT 1 FROM musehub_labels "
426 "WHERE repo_id = :repo_id AND name = :name"
427 ),
428 {"repo_id": repo_id, "name": label_def["name"]},
429 )
430 if existing.scalar_one_or_none() is not None:
431 continue # Already seeded — skip.
432 from datetime import datetime, timezone
433 seed_now = datetime.now(timezone.utc)
434 await db.execute(
435 text(
436 "INSERT INTO musehub_labels (id, repo_id, name, color, description, created_at) "
437 "VALUES (:label_id, :repo_id, :name, :color, :description, :created_at)"
438 ),
439 {
440 "label_id": compute_label_id(repo_id, label_def["name"], seed_now.isoformat()),
441 "repo_id": repo_id,
442 "name": label_def["name"],
443 "color": label_def["color"],
444 "description": label_def.get("description"),
445 "created_at": seed_now,
446 },
447 )
448 logger.info("✅ Seeded default labels for repo %s", repo_id)
File History 1 commit
sha256:7281683f5c41e5d88b6d8811fbdafebd3e01a0c9dcd90975cfcb444ba71e8e81 docs: add local source-of-truth for musehub#225, #226, #227… Sonnet 5 1 day ago