hoja-cli 0.21.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. VERSION +1 -0
  2. google_workspace/__init__.py +19 -0
  3. google_workspace/client.py +253 -0
  4. google_workspace/config.py +39 -0
  5. google_workspace/errors.py +19 -0
  6. google_workspace/mcp_server.py +100 -0
  7. google_workspace/oauth.py +131 -0
  8. google_workspace/principal.py +83 -0
  9. google_workspace/py.typed +0 -0
  10. google_workspace/tools.py +64 -0
  11. google_workspace/writes.py +44 -0
  12. hoja/__init__.py +29 -0
  13. hoja/__main__.py +6 -0
  14. hoja/auth.py +42 -0
  15. hoja/cli.py +161 -0
  16. hoja/cli_state.py +55 -0
  17. hoja/client.py +268 -0
  18. hoja/commands/__init__.py +4 -0
  19. hoja/commands/auth_cmd.py +271 -0
  20. hoja/commands/comment.py +149 -0
  21. hoja/commands/cycle.py +75 -0
  22. hoja/commands/init.py +108 -0
  23. hoja/commands/issue.py +398 -0
  24. hoja/commands/label.py +163 -0
  25. hoja/commands/memory.py +240 -0
  26. hoja/commands/project.py +56 -0
  27. hoja/commands/raw.py +50 -0
  28. hoja/commands/team.py +102 -0
  29. hoja/commands/update.py +162 -0
  30. hoja/config.py +362 -0
  31. hoja/errors.py +107 -0
  32. hoja/filters.py +150 -0
  33. hoja/graphql/__init__.py +5 -0
  34. hoja/graphql/fragments.py +138 -0
  35. hoja/graphql/mutations.py +160 -0
  36. hoja/graphql/queries.py +245 -0
  37. hoja/mcp/__init__.py +27 -0
  38. hoja/mcp/content.py +151 -0
  39. hoja/mcp/prompts.py +298 -0
  40. hoja/mcp/resources.py +213 -0
  41. hoja/mcp/server.py +189 -0
  42. hoja/memory_board.py +370 -0
  43. hoja/models/__init__.py +37 -0
  44. hoja/models/base.py +64 -0
  45. hoja/models/cycle.py +32 -0
  46. hoja/models/enums.py +62 -0
  47. hoja/models/issue.py +99 -0
  48. hoja/models/label.py +17 -0
  49. hoja/models/project.py +60 -0
  50. hoja/models/team.py +31 -0
  51. hoja/models/user.py +32 -0
  52. hoja/models/workflow.py +65 -0
  53. hoja/output.py +410 -0
  54. hoja/skill_content/commands.md +222 -0
  55. hoja/skill_content/examples.md +193 -0
  56. hoja/skill_content/filters.md +101 -0
  57. hoja/skill_content/output-formats.md +92 -0
  58. hoja/skill_content/overview.md +123 -0
  59. hoja/skill_content/workflows.md +174 -0
  60. hoja_cli-0.21.4.dist-info/METADATA +279 -0
  61. hoja_cli-0.21.4.dist-info/RECORD +107 -0
  62. hoja_cli-0.21.4.dist-info/WHEEL +4 -0
  63. hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
  64. hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
  65. hoja_server/README.md +135 -0
  66. hoja_server/__init__.py +11 -0
  67. hoja_server/abac.py +116 -0
  68. hoja_server/agent_chat.py +280 -0
  69. hoja_server/agent_conversations.py +91 -0
  70. hoja_server/agent_durable.py +332 -0
  71. hoja_server/agent_prompt.py +117 -0
  72. hoja_server/agent_threads.py +96 -0
  73. hoja_server/agent_uploads.py +60 -0
  74. hoja_server/app.py +2189 -0
  75. hoja_server/cadence.py +231 -0
  76. hoja_server/cli.py +191 -0
  77. hoja_server/cloudflare_realtime.py +110 -0
  78. hoja_server/comment_policy.py +70 -0
  79. hoja_server/connector_actions.py +119 -0
  80. hoja_server/connectors_api.py +375 -0
  81. hoja_server/db.py +1816 -0
  82. hoja_server/denials.py +18 -0
  83. hoja_server/deployment.py +28 -0
  84. hoja_server/email_adapter.py +94 -0
  85. hoja_server/google_login.py +217 -0
  86. hoja_server/google_oidc.py +342 -0
  87. hoja_server/identity_transfer.py +210 -0
  88. hoja_server/mcp_oauth.py +623 -0
  89. hoja_server/mcp_tools.py +579 -0
  90. hoja_server/meeting_policy.py +179 -0
  91. hoja_server/meetings.py +1332 -0
  92. hoja_server/meetings_api.py +909 -0
  93. hoja_server/model_providers.py +205 -0
  94. hoja_server/realtime.py +690 -0
  95. hoja_server/resolvers.py +1281 -0
  96. hoja_server/schema.graphql +477 -0
  97. hoja_server/schema_sdl.py +8 -0
  98. hoja_server/secrets_box.py +39 -0
  99. hoja_server/signup_config.py +292 -0
  100. hoja_server/signup_gate.py +393 -0
  101. hoja_server/signup_policy.py +97 -0
  102. hoja_server/signup_store.py +577 -0
  103. hoja_server/store.py +2179 -0
  104. hoja_server/sync.py +249 -0
  105. hoja_server/tenant_context.py +131 -0
  106. hoja_server/turnstile.py +81 -0
  107. hoja_server/writer.py +1262 -0
hoja/commands/issue.py ADDED
@@ -0,0 +1,398 @@
1
+ """`hoja issue ...` commands.
2
+
3
+ Includes list/get/create/update/state/assign/prio/comment.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from typing import Optional
10
+
11
+ import typer
12
+
13
+ from hoja.auth import get_viewer
14
+ from hoja.cli_state import build_client, get_state
15
+ from hoja.errors import NotFoundError, UsageError
16
+ from hoja.filters import build_issue_filter
17
+ from hoja.graphql import mutations, queries
18
+ from hoja.models.issue import Issue
19
+ from hoja.models.project import Project
20
+ from hoja.models.team import Team
21
+ from hoja.output import render
22
+
23
+ issue_app = typer.Typer(help="Issues")
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # list
28
+ # ---------------------------------------------------------------------------
29
+
30
+ @issue_app.command("list")
31
+ def list_issues(
32
+ team: Optional[str] = typer.Option(None, "--team", "-t", help="Team key (e.g. ENG) or UUID"),
33
+ state: Optional[str] = typer.Option(None, "--state", "-s", help="Workflow state name. Comma-sep for OR."),
34
+ not_state: Optional[str] = typer.Option(None, "--not-state", help="Exclude states (comma-sep)"),
35
+ state_type: Optional[str] = typer.Option(None, "--state-type", help="State type (backlog|started|completed|...)"),
36
+ assignee: Optional[str] = typer.Option(None, "--assignee", "-a", help="User. 'me' for self, or email/UUID"),
37
+ creator: Optional[str] = typer.Option(None, "--creator", help="User who created the issue"),
38
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project name or UUID"),
39
+ cycle: Optional[str] = typer.Option(None, "--cycle", "-c", help="'current', 'next', UUID, or name"),
40
+ label: Optional[str] = typer.Option(None, "--label", "-l", help="Label name. Comma-sep for OR."),
41
+ priority: Optional[int] = typer.Option(None, "--priority", min=0, max=4, help="Priority 0-4"),
42
+ contains: Optional[str] = typer.Option(None, "--contains", help="Free-text in title/description"),
43
+ updated_after: Optional[str] = typer.Option(None, "--updated-after", help="ISO date or duration (e.g. -P7D)"),
44
+ updated_before: Optional[str] = typer.Option(None, "--updated-before", help="ISO date"),
45
+ due_before: Optional[str] = typer.Option(None, "--due-before", help="ISO date"),
46
+ due_after: Optional[str] = typer.Option(None, "--due-after", help="ISO date"),
47
+ limit: int = typer.Option(50, "--limit", "-n", help="Max issues to return"),
48
+ ) -> None:
49
+ """List issues, optionally filtered."""
50
+ asyncio.run(_run_list(
51
+ team=team, state=state, not_state=not_state, state_type=state_type,
52
+ assignee=assignee, creator=creator, project=project, cycle=cycle,
53
+ label=label, priority=priority, contains=contains,
54
+ updated_after=updated_after, updated_before=updated_before,
55
+ due_before=due_before, due_after=due_after, limit=limit,
56
+ ))
57
+
58
+
59
+ @issue_app.command("get")
60
+ def get_issue(
61
+ id: str = typer.Argument(..., help="Issue identifier (e.g. ENG-123) or UUID"),
62
+ ) -> None:
63
+ """Get a single issue (full detail)."""
64
+ asyncio.run(_run_get(id))
65
+
66
+
67
+ @issue_app.command("create")
68
+ def create_issue(
69
+ team: str = typer.Option(..., "--team", "-t", help="Team key or UUID"),
70
+ title: str = typer.Option(..., "--title", help="Issue title"),
71
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Markdown description"),
72
+ priority: Optional[int] = typer.Option(None, "--priority", "-p", min=0, max=4),
73
+ assignee: Optional[str] = typer.Option(None, "--assignee", "-a", help="'me', email, or UUID"),
74
+ project: Optional[str] = typer.Option(None, "--project", help="Project UUID, slugId, or name"),
75
+ cycle: Optional[str] = typer.Option(None, "--cycle", help="Cycle UUID"),
76
+ label: Optional[list[str]] = typer.Option(None, "--label", "-l", help="Label name(s)"),
77
+ due_date: Optional[str] = typer.Option(None, "--due-date", help="ISO date YYYY-MM-DD"),
78
+ parent: Optional[str] = typer.Option(None, "--parent", help="Parent issue UUID or identifier"),
79
+ ) -> None:
80
+ """Create a new issue."""
81
+ asyncio.run(_run_create(
82
+ team=team, title=title, description=description, priority=priority,
83
+ assignee=assignee, project=project, cycle=cycle, label=label or [],
84
+ due_date=due_date, parent=parent,
85
+ ))
86
+
87
+
88
+ @issue_app.command("update")
89
+ def update_issue(
90
+ id: str = typer.Argument(..., help="Issue identifier or UUID"),
91
+ title: Optional[str] = typer.Option(None, "--title"),
92
+ description: Optional[str] = typer.Option(None, "--description", "-d"),
93
+ state: Optional[str] = typer.Option(None, "--state", "-s", help="State name"),
94
+ assignee: Optional[str] = typer.Option(None, "--assignee", "-a", help="'me', email, or UUID"),
95
+ priority: Optional[int] = typer.Option(None, "--priority", "-p", min=0, max=4),
96
+ project: Optional[str] = typer.Option(None, "--project", help="Project UUID, slugId, or name"),
97
+ cycle: Optional[str] = typer.Option(None, "--cycle", help="Cycle UUID"),
98
+ due_date: Optional[str] = typer.Option(None, "--due-date", help="ISO date"),
99
+ label: Optional[list[str]] = typer.Option(
100
+ None, "--label", "-l",
101
+ help="Replace labels (use multiple --label or comma-separated)",
102
+ ),
103
+ ) -> None:
104
+ """Update fields on an existing issue."""
105
+ asyncio.run(_run_update(
106
+ id=id, title=title, description=description, state=state,
107
+ assignee=assignee, priority=priority, project=project, cycle=cycle,
108
+ due_date=due_date, label=label or [],
109
+ ))
110
+
111
+
112
+ @issue_app.command("state")
113
+ def change_state(
114
+ id: str = typer.Argument(...),
115
+ state: str = typer.Argument(..., help="State name (must match team's workflow)"),
116
+ ) -> None:
117
+ """Quick-change the workflow state of an issue."""
118
+ asyncio.run(_run_update(id=id, state=state))
119
+
120
+
121
+ @issue_app.command("assign")
122
+ def assign_issue(
123
+ id: str = typer.Argument(...),
124
+ user: str = typer.Argument(..., help="'me', email, or user UUID"),
125
+ ) -> None:
126
+ """Quick-assign an issue."""
127
+ asyncio.run(_run_update(id=id, assignee=user))
128
+
129
+
130
+ @issue_app.command("prio")
131
+ def change_priority(
132
+ id: str = typer.Argument(...),
133
+ priority: int = typer.Argument(..., min=0, max=4),
134
+ ) -> None:
135
+ """Quick-change issue priority (0=none, 1=urgent, 2=high, 3=med, 4=low)."""
136
+ asyncio.run(_run_update(id=id, priority=priority))
137
+
138
+
139
+ @issue_app.command("url")
140
+ def issue_url(id: str = typer.Argument(...)) -> None:
141
+ """Print the Linear URL for an issue."""
142
+ asyncio.run(_run_url(id))
143
+
144
+
145
+ @issue_app.command("search")
146
+ def search_issues(
147
+ query: str = typer.Argument(..., help="Free-text search query"),
148
+ limit: int = typer.Option(20, "--limit", "-n"),
149
+ ) -> None:
150
+ """Full-text search across issues."""
151
+ asyncio.run(_run_search(query, limit))
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # runners
156
+ # ---------------------------------------------------------------------------
157
+
158
+ async def _run_list(**kwargs) -> None:
159
+ state = get_state()
160
+ limit = kwargs.pop("limit", 50)
161
+ async with build_client(state) as client:
162
+ viewer_id: str | None = None
163
+ if (kwargs.get("assignee") or "").lower() == "me" or (
164
+ kwargs.get("creator") or ""
165
+ ).lower() == "me":
166
+ viewer = await get_viewer(client)
167
+ viewer_id = viewer.id
168
+
169
+ filt = build_issue_filter(viewer_id=viewer_id, **kwargs)
170
+ issues = await client.execute_list(
171
+ Issue,
172
+ queries.ISSUES_LIST,
173
+ {"filter": filt or None, "first": limit},
174
+ path=["issues"],
175
+ operation="Issues",
176
+ )
177
+ render(issues, fmt=state.output, title="Issues")
178
+
179
+
180
+ async def _run_get(id: str) -> None:
181
+ state = get_state()
182
+ async with build_client(state) as client:
183
+ issue = await client.execute_as(
184
+ Issue,
185
+ queries.ISSUE_BY_IDENTIFIER,
186
+ {"id": id},
187
+ path=["issue"],
188
+ operation="IssueByIdentifier",
189
+ )
190
+ render(issue, fmt=state.output, title=issue.identifier)
191
+
192
+
193
+ async def _resolve_team_id(client, key: str) -> str:
194
+ if len(key) == 36 and key.count("-") == 4:
195
+ return key
196
+ teams = await client.execute_list(
197
+ Team, queries.TEAM_BY_KEY, {"key": key.upper()},
198
+ path=["teams"], operation="TeamByKey",
199
+ )
200
+ if not teams:
201
+ raise NotFoundError(f"No team with key {key!r}")
202
+ return teams[0].id
203
+
204
+
205
+ async def _resolve_project_id(client, value: str) -> str:
206
+ """Resolve a project identifier (UUID, slugId, or name) to a full UUID.
207
+
208
+ Linear's ``projectId`` field in mutations requires the full 36-char UUID.
209
+ Users often copy the shorter ``slugId`` (e.g. ``24a5eb4e800e``) from the
210
+ Linear UI, which the API rejects with "Argument Validation Error".
211
+ This helper resolves slugIds and names to the canonical UUID.
212
+ """
213
+ if len(value) == 36 and value.count("-") == 4:
214
+ return value
215
+ # Try slugId first (most common case when copy-pasting from Linear UI)
216
+ projects = await client.execute_list(
217
+ Project, queries.PROJECTS_LIST,
218
+ {"filter": {"slugId": {"eq": value}}, "first": 1},
219
+ path=["projects"], operation="Projects",
220
+ )
221
+ if projects:
222
+ return projects[0].id
223
+ # Fallback: try name match
224
+ projects = await client.execute_list(
225
+ Project, queries.PROJECTS_LIST,
226
+ {"filter": {"name": {"eqIgnoreCase": value}}, "first": 1},
227
+ path=["projects"], operation="Projects",
228
+ )
229
+ if projects:
230
+ return projects[0].id
231
+ raise NotFoundError(
232
+ f"No project with UUID, slugId, or name {value!r}. "
233
+ "Use `hoja project list` to see available projects."
234
+ )
235
+
236
+
237
+ async def _resolve_user_id(client, user: str) -> str:
238
+ if user.lower() == "me":
239
+ return (await get_viewer(client)).id
240
+ if len(user) == 36 and user.count("-") == 4:
241
+ return user
242
+ # Otherwise fall through to email — Linear's IssueUpdateInput requires user UUID
243
+ raise UsageError(
244
+ f"Cannot resolve user {user!r} to a UUID without an extra lookup. "
245
+ "Pass a UUID or 'me' for this operation."
246
+ )
247
+
248
+
249
+ async def _resolve_state_id(client, issue_id: str, state_name: str) -> str:
250
+ """Look up the state UUID for a given state name on the issue's team."""
251
+ issue = await client.execute_as(
252
+ Issue, queries.ISSUE_BY_IDENTIFIER, {"id": issue_id},
253
+ path=["issue"], operation="IssueByIdentifier",
254
+ )
255
+ if not issue.team:
256
+ raise NotFoundError(f"Issue {issue_id} has no team")
257
+ team_id = issue.team["id"]
258
+ from hoja.models.workflow import WorkflowState
259
+ states = await client.execute_list(
260
+ WorkflowState, queries.TEAM_STATES, {"teamId": team_id},
261
+ path=["team", "states"], operation="TeamStates",
262
+ )
263
+ for s in states:
264
+ if s.name.lower() == state_name.lower():
265
+ return s.id
266
+ available = ", ".join(s.name for s in states)
267
+ raise NotFoundError(
268
+ f"No state named {state_name!r} on team. Available: {available}"
269
+ )
270
+
271
+
272
+ async def _run_create(**kwargs) -> None:
273
+ state = get_state()
274
+ async with build_client(state) as client:
275
+ team_id = await _resolve_team_id(client, kwargs["team"])
276
+ input_: dict = {
277
+ "teamId": team_id,
278
+ "title": kwargs["title"],
279
+ }
280
+ if kwargs.get("description"):
281
+ input_["description"] = kwargs["description"]
282
+ if kwargs.get("priority") is not None:
283
+ input_["priority"] = kwargs["priority"]
284
+ if kwargs.get("assignee"):
285
+ input_["assigneeId"] = await _resolve_user_id(client, kwargs["assignee"])
286
+ if kwargs.get("project"):
287
+ input_["projectId"] = await _resolve_project_id(client, kwargs["project"])
288
+ if kwargs.get("cycle"):
289
+ input_["cycleId"] = kwargs["cycle"]
290
+ if kwargs.get("due_date"):
291
+ input_["dueDate"] = kwargs["due_date"]
292
+ if kwargs.get("parent"):
293
+ input_["parentId"] = kwargs["parent"]
294
+ labels = kwargs.get("label") or []
295
+ # Allow comma-separated single flag value too
296
+ flat_labels: list[str] = []
297
+ for v in labels:
298
+ flat_labels.extend(p.strip() for p in v.split(",") if p.strip())
299
+ if flat_labels:
300
+ from hoja.commands.label import resolve_label_ids
301
+ input_["labelIds"] = await resolve_label_ids(
302
+ client, team_id=team_id, names=flat_labels
303
+ )
304
+
305
+ if state.dry_run:
306
+ from hoja.output import emit_error
307
+ emit_error("dry-run: not executing", hint=str({"input": input_}))
308
+ return
309
+
310
+ data = await client.execute(
311
+ mutations.ISSUE_CREATE,
312
+ {"input": input_},
313
+ operation="IssueCreate",
314
+ )
315
+ payload = data.get("issueCreate", {})
316
+ if not payload.get("success"):
317
+ raise NotFoundError("issueCreate returned success=false")
318
+ issue = Issue.model_validate(payload["issue"])
319
+ render(issue, fmt=state.output, title=f"Created {issue.identifier}")
320
+
321
+
322
+ async def _run_update(**kwargs) -> None:
323
+ state = get_state()
324
+ issue_id = kwargs.pop("id")
325
+ async with build_client(state) as client:
326
+ input_: dict = {}
327
+ if kwargs.get("title"):
328
+ input_["title"] = kwargs["title"]
329
+ if kwargs.get("description"):
330
+ input_["description"] = kwargs["description"]
331
+ if kwargs.get("priority") is not None:
332
+ input_["priority"] = kwargs["priority"]
333
+ if kwargs.get("assignee"):
334
+ input_["assigneeId"] = await _resolve_user_id(client, kwargs["assignee"])
335
+ if kwargs.get("project"):
336
+ input_["projectId"] = await _resolve_project_id(client, kwargs["project"])
337
+ if kwargs.get("cycle"):
338
+ input_["cycleId"] = kwargs["cycle"]
339
+ if kwargs.get("due_date"):
340
+ input_["dueDate"] = kwargs["due_date"]
341
+ if kwargs.get("state"):
342
+ input_["stateId"] = await _resolve_state_id(client, issue_id, kwargs["state"])
343
+
344
+ labels = kwargs.get("label") or []
345
+ flat_labels: list[str] = []
346
+ for v in labels:
347
+ flat_labels.extend(p.strip() for p in v.split(",") if p.strip())
348
+ if flat_labels:
349
+ # Need the team to resolve labels — fetch issue first
350
+ issue = await client.execute_as(
351
+ Issue, queries.ISSUE_BY_IDENTIFIER, {"id": issue_id},
352
+ path=["issue"], operation="IssueByIdentifier",
353
+ )
354
+ if not issue.team:
355
+ raise NotFoundError(f"Issue {issue_id} has no team")
356
+ from hoja.commands.label import resolve_label_ids
357
+ input_["labelIds"] = await resolve_label_ids(
358
+ client, team_id=issue.team["id"], names=flat_labels
359
+ )
360
+
361
+ if not input_:
362
+ raise UsageError("Nothing to update — pass at least one field")
363
+
364
+ if state.dry_run:
365
+ from hoja.output import emit_error
366
+ emit_error("dry-run: not executing", hint=str({"id": issue_id, "input": input_}))
367
+ return
368
+
369
+ data = await client.execute(
370
+ mutations.ISSUE_UPDATE,
371
+ {"id": issue_id, "input": input_},
372
+ operation="IssueUpdate",
373
+ )
374
+ payload = data.get("issueUpdate", {})
375
+ if not payload.get("success"):
376
+ raise NotFoundError("issueUpdate returned success=false")
377
+ issue = Issue.model_validate(payload["issue"])
378
+ render(issue, fmt=state.output, title=f"Updated {issue.identifier}")
379
+
380
+
381
+ async def _run_url(id: str) -> None:
382
+ state = get_state()
383
+ async with build_client(state) as client:
384
+ issue = await client.execute_as(
385
+ Issue, queries.ISSUE_BY_IDENTIFIER, {"id": id},
386
+ path=["issue"], operation="IssueByIdentifier",
387
+ )
388
+ print(issue.url or "")
389
+
390
+
391
+ async def _run_search(query: str, limit: int) -> None:
392
+ state = get_state()
393
+ async with build_client(state) as client:
394
+ issues = await client.execute_list(
395
+ Issue, queries.SEARCH_ISSUES, {"term": query, "first": limit},
396
+ path=["searchIssues"], operation="SearchIssues",
397
+ )
398
+ render(issues, fmt=state.output, title=f"Search: {query!r}")
hoja/commands/label.py ADDED
@@ -0,0 +1,163 @@
1
+ """`hoja label ...` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from hoja.cli_state import build_client, get_state
11
+ from hoja.errors import NotFoundError, UsageError
12
+ from hoja.graphql import mutations, queries
13
+ from hoja.models.issue import IssueLabel
14
+ from hoja.models.team import Team
15
+ from hoja.output import render
16
+
17
+ label_app = typer.Typer(help="Labels")
18
+
19
+
20
+ @label_app.command("list")
21
+ def list_labels(
22
+ team: Optional[str] = typer.Option(None, "--team", "-t", help="Filter to team key/UUID"),
23
+ limit: int = typer.Option(100, "--limit", "-n"),
24
+ ) -> None:
25
+ """List issue labels (optionally scoped to a team)."""
26
+ asyncio.run(_run_list(team, limit))
27
+
28
+
29
+ @label_app.command("create")
30
+ def create_label(
31
+ name: str = typer.Argument(..., help="Label name"),
32
+ team: str = typer.Option(..., "--team", "-t", help="Team key or UUID"),
33
+ color: Optional[str] = typer.Option(None, "--color", help="Hex color (e.g. #ff0000)"),
34
+ description: Optional[str] = typer.Option(None, "--description", "-d"),
35
+ ) -> None:
36
+ """Create a new label on a team."""
37
+ asyncio.run(_run_create(name, team, color, description))
38
+
39
+
40
+ @label_app.command("delete")
41
+ def delete_label(
42
+ id: str = typer.Argument(..., help="Label UUID"),
43
+ ) -> None:
44
+ """Delete a label by UUID."""
45
+ asyncio.run(_run_delete(id))
46
+
47
+
48
+ # ---------- runners ----------
49
+
50
+ async def _resolve_team_id(client, key: str) -> str:
51
+ if len(key) == 36 and key.count("-") == 4:
52
+ return key
53
+ teams = await client.execute_list(
54
+ Team, queries.TEAM_BY_KEY, {"key": key.upper()},
55
+ path=["teams"], operation="TeamByKey",
56
+ )
57
+ if not teams:
58
+ raise NotFoundError(f"No team with key {key!r}")
59
+ return teams[0].id
60
+
61
+
62
+ async def _run_list(team: str | None, limit: int) -> None:
63
+ state = get_state()
64
+ async with build_client(state) as client:
65
+ filt: dict | None = None
66
+ if team:
67
+ team_id = await _resolve_team_id(client, team)
68
+ filt = {"team": {"id": {"eq": team_id}}}
69
+ labels = await client.execute_list(
70
+ IssueLabel,
71
+ queries.ISSUE_LABELS,
72
+ {"filter": filt, "first": limit},
73
+ path=["issueLabels"],
74
+ operation="IssueLabels",
75
+ )
76
+ render(labels, fmt=state.output, title="Labels")
77
+
78
+
79
+ async def _run_create(
80
+ name: str, team: str, color: str | None, description: str | None
81
+ ) -> None:
82
+ state = get_state()
83
+ async with build_client(state) as client:
84
+ team_id = await _resolve_team_id(client, team)
85
+ input_: dict = {"name": name, "teamId": team_id}
86
+ if color:
87
+ input_["color"] = color
88
+ if description:
89
+ input_["description"] = description
90
+ if state.dry_run:
91
+ from hoja.output import emit_error
92
+ emit_error("dry-run: not executing", hint=str({"input": input_}))
93
+ return
94
+ data = await client.execute(
95
+ mutations.LABEL_CREATE, {"input": input_}, operation="LabelCreate"
96
+ )
97
+ payload = data.get("issueLabelCreate", {})
98
+ if not payload.get("success"):
99
+ raise UsageError("issueLabelCreate returned success=false")
100
+ label = IssueLabel.model_validate(payload["issueLabel"])
101
+ render(label, fmt=state.output, title=f"Created label: {label.name}")
102
+
103
+
104
+ async def _run_delete(id: str) -> None:
105
+ state = get_state()
106
+ async with build_client(state) as client:
107
+ if state.dry_run:
108
+ from hoja.output import emit_error
109
+ emit_error("dry-run: not executing", hint=f"delete label {id}")
110
+ return
111
+ data = await client.execute(
112
+ mutations.LABEL_DELETE, {"id": id}, operation="LabelDelete"
113
+ )
114
+ ok = data.get("issueLabelDelete", {}).get("success")
115
+ if not ok:
116
+ raise UsageError("issueLabelDelete returned success=false")
117
+ from hoja.models.enums import OutputFormat
118
+ if state.output == OutputFormat.JSON:
119
+ import json as _json
120
+ print(_json.dumps({"deleted": id, "success": True}))
121
+ else:
122
+ print(f"Deleted label {id}")
123
+
124
+
125
+ # Helper used by issue.py to resolve label names → UUIDs for a team
126
+ async def resolve_label_ids(
127
+ client, *, team_id: str, names: list[str]
128
+ ) -> list[str]:
129
+ """Resolve label names to UUIDs for a given team.
130
+
131
+ Names that don't resolve raise UsageError listing available labels.
132
+ """
133
+ if not names:
134
+ return []
135
+ filt = {"team": {"id": {"eq": team_id}}}
136
+ labels = await client.execute_list(
137
+ IssueLabel,
138
+ queries.ISSUE_LABELS,
139
+ {"filter": filt, "first": 200},
140
+ path=["issueLabels"],
141
+ operation="IssueLabels",
142
+ )
143
+ by_name = {label.name.lower(): label.id for label in labels}
144
+ ids: list[str] = []
145
+ missing: list[str] = []
146
+ for n in names:
147
+ n = n.strip()
148
+ if not n:
149
+ continue
150
+ # Allow UUIDs to pass through
151
+ if len(n) == 36 and n.count("-") == 4:
152
+ ids.append(n)
153
+ continue
154
+ if n.lower() in by_name:
155
+ ids.append(by_name[n.lower()])
156
+ else:
157
+ missing.append(n)
158
+ if missing:
159
+ available = ", ".join(sorted(label.name for label in labels))
160
+ raise UsageError(
161
+ f"Unknown label(s): {missing}. Available on team: {available}"
162
+ )
163
+ return ids