gabriel / musehub public
labels.py python
388 lines 14.6 KB
Raw
sha256:25d96102cb2d69a038356dff26f4633156da2f1faf98fe0d0e4438ff3f367f12 refactor: rename 0054/0055 migrations to standard convention Sonnet 4.6 minor ⚠ breaking 22 days ago
1 """Write executors for label operations: create_label, update_label, delete_label.
2
3 All executors open their own DB session via ``AsyncSessionLocal`` so they can be
4 called from the MCP dispatcher without sharing a session with unrelated requests.
5 Input validation mirrors the REST API constraints (same field limits and patterns)
6 so agents get a clean error before any SQL is executed.
7 """
8
9 import logging
10 import re
11 from datetime import datetime, timezone
12
13 from musehub.core.genesis import compute_label_id
14 from musehub.types.json_types import JSONObject
15 from musehub.db.database import AsyncSessionLocal
16 from musehub.services import musehub_repository
17 from musehub.services.musehub_mcp_executor import MusehubToolResult, _check_db_available
18 from musehub.mcp.write_tools.issues import _require_write_access
19
20 logger = logging.getLogger(__name__)
21
22 # Mirrors REST API constraints — keep in sync with labels.py.
23 _MAX_LABEL_NAME = 50
24 _MAX_LABEL_DESC = 200
25 _HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
26
27 def _validate_color(color: str) -> bool:
28 """Return True iff *color* is a valid 7-char hex colour string (e.g. '#d73a4a')."""
29 return bool(_HEX_COLOR_RE.match(color))
30
31 async def execute_create_label(
32 *,
33 repo_id: str,
34 name: str,
35 color: str,
36 description: str = "",
37 actor: str = "",
38 ) -> MusehubToolResult:
39 """Create a repo-scoped label with a name and hex colour.
40
41 Label names must be unique within the repository. The ``color`` must be a
42 7-character hex string starting with ``#`` (e.g. ``'#d73a4a'``). Pass
43 ``description`` to attach a human-readable explanation.
44
45 Args:
46 repo_id: sha256 genesis ID of the repository.
47 name: Label name (unique per repo, ≤ 50 chars).
48 color: 7-char hex colour string starting with ``#`` (e.g. ``'#d73a4a'``).
49 description: Optional label description (≤ 200 chars).
50 actor: Authenticated user handle (unused for DB write; logged for audit).
51
52 Returns:
53 ``MusehubToolResult`` with ``data.label_id`` on success.
54 """
55 if (err := _check_db_available()) is not None:
56 return err
57
58 # ── Local validation — fail before touching the DB ────────────────────────
59 name = name.strip()
60 if not name:
61 return MusehubToolResult(
62 ok=False,
63 error_code="invalid_args",
64 error_message="Label name must not be empty.",
65 )
66 if len(name) > _MAX_LABEL_NAME:
67 return MusehubToolResult(
68 ok=False,
69 error_code="invalid_args",
70 error_message=f"Label name is too long ({len(name)} chars); maximum is {_MAX_LABEL_NAME}.",
71 )
72 color = color.strip()
73 if not _validate_color(color):
74 return MusehubToolResult(
75 ok=False,
76 error_code="invalid_args",
77 error_message=(
78 f"Invalid colour '{color}'. "
79 "Must be a 7-character hex string starting with '#' (e.g. '#d73a4a')."
80 ),
81 )
82 if len(description) > _MAX_LABEL_DESC:
83 return MusehubToolResult(
84 ok=False,
85 error_code="invalid_args",
86 error_message=f"Description is too long ({len(description)} chars); maximum is {_MAX_LABEL_DESC}.",
87 )
88
89 try:
90 from sqlalchemy import text
91
92 async with AsyncSessionLocal() as session:
93 repo = await musehub_repository.get_repo(session, repo_id)
94 if repo is None:
95 return MusehubToolResult(
96 ok=False,
97 error_code="repo_not_found",
98 error_message=f"Repository '{repo_id}' not found.",
99 hint="Call musehub_search_repos() to find available repositories.",
100 )
101 if (err := await _require_write_access(session, repo_id, actor, repo.owner)) is not None:
102 return err
103
104 existing = await session.execute(
105 text(
106 "SELECT 1 FROM musehub_labels "
107 "WHERE repo_id = :repo_id AND name = :name"
108 ),
109 {"repo_id": repo_id, "name": name},
110 )
111 if existing.scalar_one_or_none() is not None:
112 return MusehubToolResult(
113 ok=False,
114 error_code="already_exists",
115 error_message=f"Label '{name}' already exists in repository '{repo_id}'.",
116 )
117
118 _now = datetime.now(timezone.utc)
119 label_id = compute_label_id(repo_id, name, _now.isoformat())
120 await session.execute(
121 text(
122 "INSERT INTO musehub_labels "
123 "(id, repo_id, name, color, description, created_at) "
124 "VALUES (:label_id, :repo_id, :name, :color, :description, :created_at)"
125 ),
126 {
127 "label_id": label_id,
128 "repo_id": repo_id,
129 "name": name,
130 "color": color,
131 "description": description,
132 "created_at": _now,
133 },
134 )
135 await session.commit()
136
137 data: JSONObject = {
138 "label_id": label_id,
139 "repo_id": repo_id,
140 "name": name,
141 "color": color,
142 "description": description,
143 }
144 logger.info("MCP create_label '%s' (%s) in repo %s", name, label_id, repo_id)
145 return MusehubToolResult(ok=True, data=data)
146
147 except Exception as exc:
148 logger.exception("MCP create_label failed: %s", exc)
149 return MusehubToolResult(
150 ok=False,
151 error_code="internal_error",
152 error_message=str(exc),
153 )
154
155 async def execute_update_label(
156 *,
157 repo_id: str,
158 label_id: str,
159 name: str | None = None,
160 color: str | None = None,
161 description: str | None = None,
162 actor: str = "",
163 ) -> MusehubToolResult:
164 """Partially update an existing label.
165
166 Only supplied fields are changed; omitted fields are left unchanged. Pass
167 ``name`` to rename the label (still must be unique within the repo).
168
169 Args:
170 repo_id: sha256 genesis ID of the repository.
171 label_id: ID of the label to update.
172 name: New label name (optional, ≤ 50 chars).
173 color: New hex colour (optional, e.g. ``'#b60205'``).
174 description: New description (optional, ≤ 200 chars; pass ``""`` to clear).
175 actor: Authenticated user handle (logged for audit).
176
177 Returns:
178 ``MusehubToolResult`` with the updated label data on success.
179 """
180 if (err := _check_db_available()) is not None:
181 return err
182
183 if name is None and color is None and description is None:
184 return MusehubToolResult(
185 ok=False,
186 error_code="invalid_args",
187 error_message="Provide at least one of: name, color, description.",
188 )
189
190 # ── Local validation ──────────────────────────────────────────────────────
191 if name is not None:
192 name = name.strip()
193 if not name:
194 return MusehubToolResult(
195 ok=False,
196 error_code="invalid_args",
197 error_message="New label name must not be empty.",
198 )
199 if len(name) > _MAX_LABEL_NAME:
200 return MusehubToolResult(
201 ok=False,
202 error_code="invalid_args",
203 error_message=f"New name is too long ({len(name)} chars); maximum is {_MAX_LABEL_NAME}.",
204 )
205 if color is not None:
206 color = color.strip()
207 if not _validate_color(color):
208 return MusehubToolResult(
209 ok=False,
210 error_code="invalid_args",
211 error_message=(
212 f"Invalid colour '{color}'. "
213 "Must be a 7-character hex string starting with '#'."
214 ),
215 )
216 if description is not None and len(description) > _MAX_LABEL_DESC:
217 return MusehubToolResult(
218 ok=False,
219 error_code="invalid_args",
220 error_message=f"Description is too long ({len(description)} chars); maximum is {_MAX_LABEL_DESC}.",
221 )
222
223 try:
224 from sqlalchemy import text
225
226 async with AsyncSessionLocal() as session:
227 repo = await musehub_repository.get_repo(session, repo_id)
228 if repo is None:
229 return MusehubToolResult(
230 ok=False,
231 error_code="repo_not_found",
232 error_message=f"Repository '{repo_id}' not found.",
233 hint="Call musehub_search_repos() to find available repositories.",
234 )
235 if (err := await _require_write_access(session, repo_id, actor, repo.owner)) is not None:
236 return err
237
238 # Fetch existing label.
239 row = await session.execute(
240 text(
241 "SELECT id AS label_id, repo_id, name, color, description "
242 "FROM musehub_labels "
243 "WHERE id = :label_id AND repo_id = :repo_id"
244 ),
245 {"label_id": label_id, "repo_id": repo_id},
246 )
247 existing = row.mappings().one_or_none()
248 if existing is None:
249 return MusehubToolResult(
250 ok=False,
251 error_code="not_found",
252 error_message=f"Label '{label_id}' not found in repository '{repo_id}'.",
253 hint="Call musehub_list_labels() to list available labels.",
254 )
255
256 new_name = name if name is not None else existing["name"]
257 new_color = color if color is not None else existing["color"]
258 new_description = description if description is not None else existing["description"]
259
260 # Name uniqueness check when renaming.
261 if name is not None and name != existing["name"]:
262 dupe = await session.execute(
263 text(
264 "SELECT 1 FROM musehub_labels "
265 "WHERE repo_id = :repo_id AND name = :name AND id != :label_id"
266 ),
267 {"repo_id": repo_id, "name": name, "label_id": label_id},
268 )
269 if dupe.scalar_one_or_none() is not None:
270 return MusehubToolResult(
271 ok=False,
272 error_code="already_exists",
273 error_message=f"Label '{name}' already exists in repository '{repo_id}'.",
274 )
275
276 await session.execute(
277 text(
278 "UPDATE musehub_labels "
279 "SET name = :name, color = :color, description = :description "
280 "WHERE id = :label_id AND repo_id = :repo_id"
281 ),
282 {
283 "name": new_name,
284 "color": new_color,
285 "description": new_description,
286 "label_id": label_id,
287 "repo_id": repo_id,
288 },
289 )
290 await session.commit()
291
292 data: JSONObject = {
293 "label_id": label_id,
294 "repo_id": repo_id,
295 "name": new_name,
296 "color": new_color,
297 "description": new_description,
298 }
299 logger.info("MCP update_label %s in repo %s", label_id, repo_id)
300 return MusehubToolResult(ok=True, data=data)
301
302 except Exception as exc:
303 logger.exception("MCP update_label failed: %s", exc)
304 return MusehubToolResult(
305 ok=False,
306 error_code="internal_error",
307 error_message=str(exc),
308 )
309
310 async def execute_delete_label(
311 *,
312 repo_id: str,
313 label_id: str,
314 actor: str = "",
315 ) -> MusehubToolResult:
316 """Permanently delete a label and remove it from all issues and proposals.
317
318 Args:
319 repo_id: sha256 genesis ID of the repository.
320 label_id: ID of the label to delete.
321 actor: Authenticated user handle (logged for audit).
322
323 Returns:
324 ``MusehubToolResult`` with ``data.deleted`` on success.
325 """
326 if (err := _check_db_available()) is not None:
327 return err
328
329 try:
330 from sqlalchemy import text
331
332 async with AsyncSessionLocal() as session:
333 repo = await musehub_repository.get_repo(session, repo_id)
334 if repo is None:
335 return MusehubToolResult(
336 ok=False,
337 error_code="repo_not_found",
338 error_message=f"Repository '{repo_id}' not found.",
339 hint="Call musehub_search_repos() to find available repositories.",
340 )
341 if (err := await _require_write_access(session, repo_id, actor, repo.owner)) is not None:
342 return err
343
344 row = await session.execute(
345 text(
346 "SELECT id, name FROM musehub_labels "
347 "WHERE id = :label_id AND repo_id = :repo_id"
348 ),
349 {"label_id": label_id, "repo_id": repo_id},
350 )
351 existing = row.mappings().one_or_none()
352 if existing is None:
353 return MusehubToolResult(
354 ok=False,
355 error_code="not_found",
356 error_message=f"Label '{label_id}' not found in repository '{repo_id}'.",
357 hint="Call musehub_list_labels() to list available labels.",
358 )
359
360 label_name: str = existing["name"]
361
362 # Remove proposal associations before deleting the label row.
363 await session.execute(
364 text("DELETE FROM musehub_proposal_labels WHERE label_id = :label_id"),
365 {"label_id": label_id},
366 )
367 await session.execute(
368 text("DELETE FROM musehub_labels WHERE id = :label_id AND repo_id = :repo_id"),
369 {"label_id": label_id, "repo_id": repo_id},
370 )
371 await session.commit()
372
373 data: JSONObject = {
374 "deleted": True,
375 "label_id": label_id,
376 "repo_id": repo_id,
377 "name": label_name,
378 }
379 logger.info("MCP delete_label %s ('%s') from repo %s", label_id, label_name, repo_id)
380 return MusehubToolResult(ok=True, data=data)
381
382 except Exception as exc:
383 logger.exception("MCP delete_label failed: %s", exc)
384 return MusehubToolResult(
385 ok=False,
386 error_code="internal_error",
387 error_message=str(exc),
388 )
File History 2 commits
sha256:25d96102cb2d69a038356dff26f4633156da2f1faf98fe0d0e4438ff3f367f12 refactor: rename 0054/0055 migrations to standard convention Sonnet 4.6 minor 22 days ago
sha256:4aed3d8601c8dd3ed37074de35f11f4a9699a0a4b99d43727048fd3f8e6fd13d chore: doc sweep, ignore wrangler build state, misc fixes Sonnet 4.6 minor 24 days ago