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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- 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
|