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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""`hoja me` and `hoja auth ...` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json as _json
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from hoja.auth import get_viewer
|
|
12
|
+
from hoja.cli_state import build_client, get_state
|
|
13
|
+
from hoja.client import LinearClient
|
|
14
|
+
from hoja.config import (
|
|
15
|
+
AccountConfig,
|
|
16
|
+
load_config,
|
|
17
|
+
resolve_workspace,
|
|
18
|
+
save_config,
|
|
19
|
+
)
|
|
20
|
+
from hoja.errors import AuthError, UsageError
|
|
21
|
+
from hoja.models.enums import OutputFormat
|
|
22
|
+
from hoja.output import render
|
|
23
|
+
|
|
24
|
+
me_app = typer.Typer(help="Show the currently-authenticated Linear user")
|
|
25
|
+
auth_app = typer.Typer(help="Authentication operations")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@me_app.callback(invoke_without_command=True)
|
|
29
|
+
def me(ctx: typer.Context) -> None:
|
|
30
|
+
"""Show the currently-authenticated user (alias for `auth status`)."""
|
|
31
|
+
if ctx.invoked_subcommand is not None:
|
|
32
|
+
return
|
|
33
|
+
asyncio.run(_run_me())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@auth_app.command("status")
|
|
37
|
+
def status() -> None:
|
|
38
|
+
"""Show the currently-authenticated user and token source."""
|
|
39
|
+
asyncio.run(_run_me())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@auth_app.command("whoami")
|
|
43
|
+
def whoami() -> None:
|
|
44
|
+
"""Alias of `auth status`."""
|
|
45
|
+
asyncio.run(_run_me())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _run_me() -> None:
|
|
49
|
+
state = get_state()
|
|
50
|
+
async with build_client(state) as client:
|
|
51
|
+
viewer = await get_viewer(client)
|
|
52
|
+
render(viewer, fmt=state.output, title="Viewer")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@auth_app.command("accounts")
|
|
56
|
+
def accounts_list() -> None:
|
|
57
|
+
"""List all configured accounts with default/workspace/current markers."""
|
|
58
|
+
state = get_state()
|
|
59
|
+
config = state.config
|
|
60
|
+
workspace = resolve_workspace()
|
|
61
|
+
workspace_account = (
|
|
62
|
+
config.workspaces.get(str(workspace)) if workspace else None
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
account_rows: list[dict[str, Any]] = []
|
|
66
|
+
for name, acc in config.accounts.items():
|
|
67
|
+
markers: list[str] = []
|
|
68
|
+
if name == config.defaults.default_account:
|
|
69
|
+
markers.append("default")
|
|
70
|
+
if name == workspace_account:
|
|
71
|
+
markers.append("workspace")
|
|
72
|
+
if name == state.account_name:
|
|
73
|
+
markers.append("current")
|
|
74
|
+
account_rows.append(
|
|
75
|
+
{
|
|
76
|
+
"name": name,
|
|
77
|
+
"org_name": acc.org_name or "(unknown)",
|
|
78
|
+
"token_source": "config" if acc.token else f"${acc.token_env}",
|
|
79
|
+
"teams": acc.teams,
|
|
80
|
+
"markers": markers,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if state.output == OutputFormat.JSON:
|
|
85
|
+
print(_json.dumps(account_rows, indent=2))
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Human output
|
|
89
|
+
if not account_rows:
|
|
90
|
+
typer.echo("No accounts configured.")
|
|
91
|
+
typer.echo("Run: hoja auth add <name> --token <token>")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
typer.echo("Configured accounts:")
|
|
95
|
+
for row in account_rows:
|
|
96
|
+
marker_str = ""
|
|
97
|
+
if row["markers"]:
|
|
98
|
+
marker_str = " [" + ", ".join(row["markers"]) + "]"
|
|
99
|
+
typer.echo(f" • {row['name']}{marker_str}")
|
|
100
|
+
if row["org_name"] != "(unknown)":
|
|
101
|
+
typer.echo(f" org: {row['org_name']}")
|
|
102
|
+
typer.echo(f" token: {row['token_source']}")
|
|
103
|
+
if row["teams"]:
|
|
104
|
+
typer.echo(f" teams: {', '.join(row['teams'])}")
|
|
105
|
+
if workspace:
|
|
106
|
+
typer.echo(f"\nWorkspace: {workspace}")
|
|
107
|
+
else:
|
|
108
|
+
typer.echo("\nWorkspace: (none — not in a git repo)")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@auth_app.command("add")
|
|
112
|
+
def account_add(
|
|
113
|
+
name: str = typer.Argument(..., help="Account name"),
|
|
114
|
+
token: str = typer.Option(..., "--token", help="Hoja API token"),
|
|
115
|
+
token_env: str = typer.Option(
|
|
116
|
+
"HOJA_TOKEN", "--token-env", help="Environment variable name"
|
|
117
|
+
),
|
|
118
|
+
verify: bool = typer.Option(
|
|
119
|
+
True, "--verify/--no-verify", help="Verify token by calling the API"
|
|
120
|
+
),
|
|
121
|
+
default: bool = typer.Option(
|
|
122
|
+
False, "--default", help="Set as global default account"
|
|
123
|
+
),
|
|
124
|
+
teams: Optional[str] = typer.Option(
|
|
125
|
+
None,
|
|
126
|
+
"--teams",
|
|
127
|
+
help="Comma-separated team keys this account owns (e.g. 'SWA,ENG'). "
|
|
128
|
+
"Enables automatic account selection when a command targets one of "
|
|
129
|
+
"these teams.",
|
|
130
|
+
),
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Add a new named account."""
|
|
133
|
+
config = load_config()
|
|
134
|
+
if name in config.accounts:
|
|
135
|
+
raise UsageError(
|
|
136
|
+
f"Account '{name}' already exists",
|
|
137
|
+
hint="Use 'hoja auth remove {name}' first, or pick a different name.",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
org_name: str | None = None
|
|
141
|
+
if verify:
|
|
142
|
+
try:
|
|
143
|
+
org_name = asyncio.run(_fetch_org_name(token))
|
|
144
|
+
except Exception as e:
|
|
145
|
+
typer.echo(f"Warning: could not verify token ({e})", err=True)
|
|
146
|
+
|
|
147
|
+
team_keys = (
|
|
148
|
+
[t.strip().upper() for t in teams.split(",") if t.strip()] if teams else []
|
|
149
|
+
)
|
|
150
|
+
was_empty = len(config.accounts) == 0
|
|
151
|
+
config.accounts[name] = AccountConfig(
|
|
152
|
+
# The shared default env vars are never persisted; only a distinct
|
|
153
|
+
# per-account variable stores the token inline.
|
|
154
|
+
token=token if token_env in ("HOJA_TOKEN", "LINEAR_TOKEN") else None,
|
|
155
|
+
token_env=token_env,
|
|
156
|
+
org_name=org_name,
|
|
157
|
+
teams=team_keys,
|
|
158
|
+
)
|
|
159
|
+
if default or was_empty:
|
|
160
|
+
config.defaults.default_account = name
|
|
161
|
+
save_config(config)
|
|
162
|
+
typer.echo(f"Added account '{name}'" + (f" ({org_name})" if org_name else ""))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def _fetch_org_name(token: str) -> str | None:
|
|
166
|
+
"""Make a quick API call to get the organization name."""
|
|
167
|
+
from hoja.graphql import queries
|
|
168
|
+
from hoja.models.user import User
|
|
169
|
+
|
|
170
|
+
client = LinearClient(token=token, timeout=10.0)
|
|
171
|
+
async with client:
|
|
172
|
+
viewer = await client.execute_as(User, queries.VIEWER, path=["viewer"], operation="Viewer")
|
|
173
|
+
# viewer.organization may not exist on the User model; handle gracefully
|
|
174
|
+
return getattr(viewer, "organization_name", None) or getattr(
|
|
175
|
+
viewer, "organization", None
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@auth_app.command("switch")
|
|
180
|
+
def account_switch(
|
|
181
|
+
name: str = typer.Argument(..., help="Account name to set as default"),
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Set the global default account."""
|
|
184
|
+
config = load_config()
|
|
185
|
+
if name not in config.accounts:
|
|
186
|
+
raise AuthError(
|
|
187
|
+
f"Account '{name}' not found",
|
|
188
|
+
hint="Run 'hoja auth accounts' to see available accounts.",
|
|
189
|
+
)
|
|
190
|
+
config.defaults.default_account = name
|
|
191
|
+
save_config(config)
|
|
192
|
+
typer.echo(f"Default account set to '{name}'")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@auth_app.command("teams")
|
|
196
|
+
def account_teams(
|
|
197
|
+
name: str = typer.Argument(..., help="Account name to edit"),
|
|
198
|
+
teams: str = typer.Argument(
|
|
199
|
+
...,
|
|
200
|
+
help="Comma-separated team keys this account owns (e.g. 'SWA,ENG'). "
|
|
201
|
+
"Pass an empty string to clear.",
|
|
202
|
+
),
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Set the team keys an account owns (for automatic account selection)."""
|
|
205
|
+
config = load_config()
|
|
206
|
+
if name not in config.accounts:
|
|
207
|
+
raise AuthError(
|
|
208
|
+
f"Account '{name}' not found",
|
|
209
|
+
hint="Run 'hoja auth accounts' to see available accounts.",
|
|
210
|
+
)
|
|
211
|
+
team_keys = [t.strip().upper() for t in teams.split(",") if t.strip()]
|
|
212
|
+
config.accounts[name].teams = team_keys
|
|
213
|
+
save_config(config)
|
|
214
|
+
if team_keys:
|
|
215
|
+
typer.echo(f"Account '{name}' now owns teams: {', '.join(team_keys)}")
|
|
216
|
+
else:
|
|
217
|
+
typer.echo(f"Cleared team ownership for account '{name}'")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@auth_app.command("remove")
|
|
221
|
+
def account_remove(
|
|
222
|
+
name: str = typer.Argument(..., help="Account name to remove"),
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Remove a named account."""
|
|
225
|
+
config = load_config()
|
|
226
|
+
if name not in config.accounts:
|
|
227
|
+
raise AuthError(
|
|
228
|
+
f"Account '{name}' not found",
|
|
229
|
+
hint="Run 'hoja auth accounts' to see available accounts.",
|
|
230
|
+
)
|
|
231
|
+
del config.accounts[name]
|
|
232
|
+
# Clean up workspace mappings pointing to this account
|
|
233
|
+
config.workspaces = {
|
|
234
|
+
k: v for k, v in config.workspaces.items() if v != name
|
|
235
|
+
}
|
|
236
|
+
if config.defaults.default_account == name:
|
|
237
|
+
config.defaults.default_account = next(iter(config.accounts), None)
|
|
238
|
+
save_config(config)
|
|
239
|
+
typer.echo(f"Removed account '{name}'")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@auth_app.command("workspace")
|
|
243
|
+
def auth_workspace() -> None:
|
|
244
|
+
"""Show current workspace detection and mapped account."""
|
|
245
|
+
state = get_state()
|
|
246
|
+
config = state.config
|
|
247
|
+
workspace = resolve_workspace()
|
|
248
|
+
|
|
249
|
+
result: dict[str, Any] = {
|
|
250
|
+
"workspace": str(workspace) if workspace else None,
|
|
251
|
+
"mapped_account": None,
|
|
252
|
+
"default_account": config.defaults.default_account,
|
|
253
|
+
}
|
|
254
|
+
if workspace:
|
|
255
|
+
mapped = config.workspaces.get(str(workspace))
|
|
256
|
+
result["mapped_account"] = mapped
|
|
257
|
+
|
|
258
|
+
if state.output == OutputFormat.JSON:
|
|
259
|
+
print(_json.dumps(result, indent=2))
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
if workspace:
|
|
263
|
+
typer.echo(f"Workspace: {workspace}")
|
|
264
|
+
if mapped := result["mapped_account"]:
|
|
265
|
+
typer.echo(f"Mapped account: {mapped}")
|
|
266
|
+
else:
|
|
267
|
+
typer.echo("No workspace mapping configured.")
|
|
268
|
+
typer.echo(f"Default account: {config.defaults.default_account or '(none)'}")
|
|
269
|
+
else:
|
|
270
|
+
typer.echo("Not inside a git repository.")
|
|
271
|
+
typer.echo(f"Default account: {config.defaults.default_account or '(none)'}")
|
hoja/commands/comment.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""`hoja comment ...` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from hoja.cli_state import build_client, get_state
|
|
12
|
+
from hoja.errors import UsageError
|
|
13
|
+
from hoja.graphql import mutations, queries
|
|
14
|
+
from hoja.models.issue import Comment, Issue
|
|
15
|
+
from hoja.output import render
|
|
16
|
+
|
|
17
|
+
comment_app = typer.Typer(help="Comments")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@comment_app.command("list")
|
|
21
|
+
def list_comments(
|
|
22
|
+
issue_id: str = typer.Argument(..., help="Issue identifier (ENG-34) or UUID"),
|
|
23
|
+
limit: int = typer.Option(50, "--limit", "-n"),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""List comments on an issue."""
|
|
26
|
+
asyncio.run(_run_list(issue_id, limit))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@comment_app.command("add")
|
|
30
|
+
def add_comment(
|
|
31
|
+
issue_id: str = typer.Argument(..., help="Issue identifier or UUID"),
|
|
32
|
+
body: Optional[str] = typer.Argument(None, help="Comment body. If omitted, reads stdin."),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Add a comment to an issue. Reads stdin if body argument is omitted."""
|
|
35
|
+
text = body if body is not None else sys.stdin.read()
|
|
36
|
+
if not text.strip():
|
|
37
|
+
raise UsageError("Empty comment body — provide an argument or pipe content")
|
|
38
|
+
asyncio.run(_run_add(issue_id, text))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@comment_app.command("edit")
|
|
42
|
+
def edit_comment(
|
|
43
|
+
comment_id: str = typer.Argument(..., help="Comment UUID"),
|
|
44
|
+
body: Optional[str] = typer.Argument(None, help="New body. If omitted, reads stdin."),
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Edit an existing comment."""
|
|
47
|
+
text = body if body is not None else sys.stdin.read()
|
|
48
|
+
if not text.strip():
|
|
49
|
+
raise UsageError("Empty body")
|
|
50
|
+
asyncio.run(_run_edit(comment_id, text))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@comment_app.command("delete")
|
|
54
|
+
def delete_comment(
|
|
55
|
+
comment_id: str = typer.Argument(..., help="Comment UUID"),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Delete a comment."""
|
|
58
|
+
asyncio.run(_run_delete(comment_id))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------- runners ----------
|
|
62
|
+
|
|
63
|
+
async def _resolve_issue_uuid(client, ref: str) -> str:
|
|
64
|
+
"""Resolve identifier like ENG-34 to a UUID; pass through if already UUID."""
|
|
65
|
+
if len(ref) == 36 and ref.count("-") == 4:
|
|
66
|
+
return ref
|
|
67
|
+
issue = await client.execute_as(
|
|
68
|
+
Issue, queries.ISSUE_BY_IDENTIFIER, {"id": ref},
|
|
69
|
+
path=["issue"], operation="IssueByIdentifier",
|
|
70
|
+
)
|
|
71
|
+
return issue.id
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _run_list(issue_ref: str, limit: int) -> None:
|
|
75
|
+
state = get_state()
|
|
76
|
+
async with build_client(state) as client:
|
|
77
|
+
data = await client.execute(
|
|
78
|
+
queries.ISSUE_COMMENTS,
|
|
79
|
+
{"issueId": issue_ref, "first": limit},
|
|
80
|
+
operation="IssueComments",
|
|
81
|
+
)
|
|
82
|
+
issue_data = data.get("issue") or {}
|
|
83
|
+
nodes = (issue_data.get("comments") or {}).get("nodes") or []
|
|
84
|
+
comments = [Comment.model_validate(n) for n in nodes]
|
|
85
|
+
render(
|
|
86
|
+
comments,
|
|
87
|
+
fmt=state.output,
|
|
88
|
+
title=f"Comments on {issue_data.get('identifier', issue_ref)}",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _run_add(issue_ref: str, body: str) -> None:
|
|
93
|
+
state = get_state()
|
|
94
|
+
async with build_client(state) as client:
|
|
95
|
+
issue_uuid = await _resolve_issue_uuid(client, issue_ref)
|
|
96
|
+
input_ = {"issueId": issue_uuid, "body": body.strip()}
|
|
97
|
+
if state.dry_run:
|
|
98
|
+
from hoja.output import emit_error
|
|
99
|
+
emit_error("dry-run: not executing", hint=str({"input": input_}))
|
|
100
|
+
return
|
|
101
|
+
data = await client.execute(
|
|
102
|
+
mutations.COMMENT_CREATE, {"input": input_}, operation="CommentCreate"
|
|
103
|
+
)
|
|
104
|
+
payload = data.get("commentCreate", {})
|
|
105
|
+
if not payload.get("success"):
|
|
106
|
+
raise UsageError("commentCreate returned success=false")
|
|
107
|
+
c = Comment.model_validate(payload["comment"])
|
|
108
|
+
render(c, fmt=state.output, title="Comment added")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _run_edit(comment_id: str, body: str) -> None:
|
|
112
|
+
state = get_state()
|
|
113
|
+
async with build_client(state) as client:
|
|
114
|
+
input_ = {"body": body.strip()}
|
|
115
|
+
if state.dry_run:
|
|
116
|
+
from hoja.output import emit_error
|
|
117
|
+
emit_error("dry-run: not executing", hint=str({"id": comment_id, "input": input_}))
|
|
118
|
+
return
|
|
119
|
+
data = await client.execute(
|
|
120
|
+
mutations.COMMENT_UPDATE,
|
|
121
|
+
{"id": comment_id, "input": input_},
|
|
122
|
+
operation="CommentUpdate",
|
|
123
|
+
)
|
|
124
|
+
payload = data.get("commentUpdate", {})
|
|
125
|
+
if not payload.get("success"):
|
|
126
|
+
raise UsageError("commentUpdate returned success=false")
|
|
127
|
+
c = Comment.model_validate(payload["comment"])
|
|
128
|
+
render(c, fmt=state.output, title="Comment updated")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _run_delete(comment_id: str) -> None:
|
|
132
|
+
state = get_state()
|
|
133
|
+
async with build_client(state) as client:
|
|
134
|
+
if state.dry_run:
|
|
135
|
+
from hoja.output import emit_error
|
|
136
|
+
emit_error("dry-run: not executing", hint=f"delete comment {comment_id}")
|
|
137
|
+
return
|
|
138
|
+
data = await client.execute(
|
|
139
|
+
mutations.COMMENT_DELETE, {"id": comment_id}, operation="CommentDelete"
|
|
140
|
+
)
|
|
141
|
+
ok = data.get("commentDelete", {}).get("success")
|
|
142
|
+
if not ok:
|
|
143
|
+
raise UsageError("commentDelete returned success=false")
|
|
144
|
+
from hoja.models.enums import OutputFormat
|
|
145
|
+
if state.output == OutputFormat.JSON:
|
|
146
|
+
import json as _json
|
|
147
|
+
print(_json.dumps({"deleted": comment_id, "success": True}))
|
|
148
|
+
else:
|
|
149
|
+
print(f"Deleted comment {comment_id}")
|
hoja/commands/cycle.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""`hoja cycle ...` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from hoja.cli_state import build_client, get_state
|
|
10
|
+
from hoja.errors import NotFoundError
|
|
11
|
+
from hoja.graphql import queries
|
|
12
|
+
from hoja.models.cycle import Cycle
|
|
13
|
+
from hoja.models.team import Team
|
|
14
|
+
from hoja.output import render
|
|
15
|
+
|
|
16
|
+
cycle_app = typer.Typer(help="Cycles (sprints)")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@cycle_app.command("current")
|
|
20
|
+
def current_cycle(team: str = typer.Argument(..., help="Team key (e.g. ENG)")) -> None:
|
|
21
|
+
"""Show the currently-active cycle for a team."""
|
|
22
|
+
asyncio.run(_run_current(team))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@cycle_app.command("list")
|
|
26
|
+
def list_cycles(team: str = typer.Argument(..., help="Team key (e.g. ENG)")) -> None:
|
|
27
|
+
"""List cycles for a team."""
|
|
28
|
+
asyncio.run(_run_list(team))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _resolve_team_id(client, key: str) -> str:
|
|
32
|
+
if len(key) == 36 and key.count("-") == 4:
|
|
33
|
+
return key
|
|
34
|
+
teams = await client.execute_list(
|
|
35
|
+
Team, queries.TEAM_BY_KEY, {"key": key.upper()},
|
|
36
|
+
path=["teams"], operation="TeamByKey",
|
|
37
|
+
)
|
|
38
|
+
if not teams:
|
|
39
|
+
raise NotFoundError(f"No team with key {key!r}")
|
|
40
|
+
return teams[0].id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _run_current(team: str) -> None:
|
|
44
|
+
state = get_state()
|
|
45
|
+
async with build_client(state) as client:
|
|
46
|
+
team_id = await _resolve_team_id(client, team)
|
|
47
|
+
data = await client.execute(
|
|
48
|
+
queries.TEAM_CYCLES, {"teamId": team_id}, operation="TeamCycles"
|
|
49
|
+
)
|
|
50
|
+
active = data.get("team", {}).get("activeCycle")
|
|
51
|
+
if not active:
|
|
52
|
+
# Graceful empty result — exit 0, but signal absence cleanly
|
|
53
|
+
from hoja.models.enums import OutputFormat
|
|
54
|
+
from hoja.output import emit_error
|
|
55
|
+
if state.output == OutputFormat.JSON:
|
|
56
|
+
import json as _json
|
|
57
|
+
print(_json.dumps({"active_cycle": None, "team": team}, indent=2))
|
|
58
|
+
elif state.output == OutputFormat.IDS:
|
|
59
|
+
pass # empty stdout — no IDs to print
|
|
60
|
+
else:
|
|
61
|
+
emit_error(f"No active cycle on team {team!r}",
|
|
62
|
+
hint="Run `hoja cycle list <team>` to see all cycles")
|
|
63
|
+
return
|
|
64
|
+
render(Cycle.model_validate(active), fmt=state.output, title="Active Cycle")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _run_list(team: str) -> None:
|
|
68
|
+
state = get_state()
|
|
69
|
+
async with build_client(state) as client:
|
|
70
|
+
team_id = await _resolve_team_id(client, team)
|
|
71
|
+
cycles = await client.execute_list(
|
|
72
|
+
Cycle, queries.TEAM_CYCLES, {"teamId": team_id},
|
|
73
|
+
path=["team", "cycles"], operation="TeamCycles",
|
|
74
|
+
)
|
|
75
|
+
render(cycles, fmt=state.output, title=f"Cycles ({team})")
|
hoja/commands/init.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""`hoja init` — scaffold the config file at the standard location."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from hoja.config import config_path
|
|
10
|
+
|
|
11
|
+
init_app = typer.Typer(help="Initialize hoja configuration")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CONFIG_TEMPLATE = """\
|
|
15
|
+
# hoja configuration file
|
|
16
|
+
# Location: __CONFIG_PATH__
|
|
17
|
+
#
|
|
18
|
+
# Token resolution order (per account):
|
|
19
|
+
# 1. --token CLI flag
|
|
20
|
+
# 2. $<ACCOUNT_TOKEN_ENV> environment variable (default: HOJA_TOKEN;
|
|
21
|
+
# the deprecated LINEAR_TOKEN is read as a fallback)
|
|
22
|
+
# 3. [accounts.<name>].token below (discouraged: plaintext in dotfiles)
|
|
23
|
+
#
|
|
24
|
+
# Account resolution order:
|
|
25
|
+
# 1. --account CLI flag
|
|
26
|
+
# 2. Workspace-mapped account (git repo → account)
|
|
27
|
+
# 3. Global default account
|
|
28
|
+
|
|
29
|
+
[accounts.default]
|
|
30
|
+
# Environment variable to read the token from. Default: HOJA_TOKEN
|
|
31
|
+
# (LINEAR_TOKEN is still honoured as a deprecated fallback).
|
|
32
|
+
token_env = "HOJA_TOKEN"
|
|
33
|
+
# Uncomment to store the token here directly (not recommended):
|
|
34
|
+
# token = "lin_api_..."
|
|
35
|
+
# Team keys this account owns — enables automatic account selection when a
|
|
36
|
+
# command targets one of these teams (e.g. `hoja issue get SWA-20`).
|
|
37
|
+
# teams = ["SWA", "ENG"]
|
|
38
|
+
|
|
39
|
+
[defaults]
|
|
40
|
+
# Default team key — used when commands accept --team but you omit it.
|
|
41
|
+
# team = "ENG"
|
|
42
|
+
output = "human"
|
|
43
|
+
# editor = "$EDITOR"
|
|
44
|
+
# Global default account when not in a mapped workspace
|
|
45
|
+
default_account = "default"
|
|
46
|
+
|
|
47
|
+
[display]
|
|
48
|
+
color = true
|
|
49
|
+
table_max_width = 120
|
|
50
|
+
truncate_descriptions = 80
|
|
51
|
+
|
|
52
|
+
# Per-workspace account mappings (absolute repo path → account name).
|
|
53
|
+
# Detected automatically when you run `hoja auth workspace`.
|
|
54
|
+
# [workspaces]
|
|
55
|
+
# "/home/user/work/acme" = "work"
|
|
56
|
+
# "/home/user/oss/project" = "personal"
|
|
57
|
+
|
|
58
|
+
# Named filter aliases. Use with: hoja issue list --view <name>
|
|
59
|
+
# (Not yet wired up in v0.2 — reserved.)
|
|
60
|
+
[views]
|
|
61
|
+
# my-open-bugs = { assignee = "me", label = "Bug", "state.type" = "!completed" }
|
|
62
|
+
# team-blockers = { team = "ENG", priority = 1, "state.type" = "started" }
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@init_app.callback(invoke_without_command=True)
|
|
67
|
+
def init(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing config"),
|
|
70
|
+
path: str | None = typer.Option(
|
|
71
|
+
None, "--path", help="Write to this path instead of the default"
|
|
72
|
+
),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Create a starter config file at the standard location."""
|
|
75
|
+
if ctx.invoked_subcommand is not None:
|
|
76
|
+
return
|
|
77
|
+
target = config_path() if path is None else _to_path(path)
|
|
78
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
if target.exists() and not force:
|
|
80
|
+
typer.echo(
|
|
81
|
+
f"Config already exists at {target}. Use --force to overwrite.",
|
|
82
|
+
err=True,
|
|
83
|
+
)
|
|
84
|
+
raise typer.Exit(2)
|
|
85
|
+
target.write_text(
|
|
86
|
+
CONFIG_TEMPLATE.replace("__CONFIG_PATH__", str(target)),
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
os.chmod(target, 0o600)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
typer.echo(f"Created {target}")
|
|
94
|
+
typer.echo("")
|
|
95
|
+
typer.echo("Next steps:")
|
|
96
|
+
typer.echo(f" 1. export HOJA_TOKEN=\"lin_api_...\" # or edit {target}")
|
|
97
|
+
typer.echo(" 2. hoja me # verify it works")
|
|
98
|
+
typer.echo("")
|
|
99
|
+
typer.echo("Multi-account setup:")
|
|
100
|
+
typer.echo(" hoja auth add work --token $HOJA_WORK_TOKEN")
|
|
101
|
+
typer.echo(" hoja auth add personal --token $HOJA_PERSONAL_TOKEN")
|
|
102
|
+
typer.echo(" hoja auth switch work")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _to_path(p: str):
|
|
106
|
+
from pathlib import Path
|
|
107
|
+
|
|
108
|
+
return Path(p).expanduser().resolve()
|