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/config.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Config loading and validation.
|
|
2
|
+
|
|
3
|
+
Lookup order:
|
|
4
|
+
1. $HOJA_CONFIG env var (absolute path)
|
|
5
|
+
2. Existing or new $XDG_CONFIG_HOME/hoja/config.toml
|
|
6
|
+
|
|
7
|
+
Token resolution order (per account):
|
|
8
|
+
1. --token CLI flag (passed in)
|
|
9
|
+
2. $<ACCOUNT_TOKEN_ENV> environment variable (default: HOJA_TOKEN,
|
|
10
|
+
with deprecated LINEAR_TOKEN still read as a fallback)
|
|
11
|
+
3. config.toml [accounts.<name>].token (discouraged)
|
|
12
|
+
|
|
13
|
+
Account resolution order:
|
|
14
|
+
1. --account CLI flag
|
|
15
|
+
2. Workspace-mapped account (git repo root → config.workspaces)
|
|
16
|
+
3. Global default account (config.defaults.default_account)
|
|
17
|
+
4. First available account (fallback)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
28
|
+
|
|
29
|
+
if sys.version_info >= (3, 11):
|
|
30
|
+
import tomllib
|
|
31
|
+
else: # pragma: no cover
|
|
32
|
+
import tomli as tomllib
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import tomli_w
|
|
36
|
+
except ImportError: # pragma: no cover
|
|
37
|
+
tomli_w = None # type: ignore[assignment]
|
|
38
|
+
|
|
39
|
+
import contextlib
|
|
40
|
+
|
|
41
|
+
from hoja.errors import AuthError, UsageError
|
|
42
|
+
|
|
43
|
+
# Deprecated environment-variable aliases kept for wire compatibility.
|
|
44
|
+
# The HOJA_* name is canonical: it is read first, and the LINEAR_* name is
|
|
45
|
+
# consulted only when the canonical variable is unset. Convenience aliases are
|
|
46
|
+
# NOT inherited by accounts that declare their own ``token_env``.
|
|
47
|
+
LEGACY_ENV_ALIASES: dict[str, str] = {
|
|
48
|
+
"HOJA_TOKEN": "LINEAR_TOKEN",
|
|
49
|
+
"HOJA_API_URL": "LINEAR_API_URL",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def env_with_legacy(name: str) -> str | None:
|
|
54
|
+
"""Read ``$name``, falling back to its deprecated alias when it has one."""
|
|
55
|
+
if value := os.environ.get(name):
|
|
56
|
+
return value
|
|
57
|
+
alias = LEGACY_ENV_ALIASES.get(name)
|
|
58
|
+
if alias and (value := os.environ.get(alias)):
|
|
59
|
+
return value
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AccountConfig(BaseModel):
|
|
64
|
+
"""A named Hoja account."""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(extra="forbid")
|
|
67
|
+
|
|
68
|
+
token: str | None = None
|
|
69
|
+
# Environment variable holding this account's token. HOJA_TOKEN is read
|
|
70
|
+
# first and the deprecated LINEAR_TOKEN is used only if HOJA_TOKEN is unset.
|
|
71
|
+
token_env: str = "HOJA_TOKEN"
|
|
72
|
+
org_name: str | None = None
|
|
73
|
+
# GraphQL endpoint override. Point this at a local hoja-serve backend
|
|
74
|
+
# (e.g. "http://127.0.0.1:8787/graphql") to run fully offline. When unset,
|
|
75
|
+
# hoja talks to the configured Linear-compatible API. $HOJA_API_URL
|
|
76
|
+
# (or its deprecated alias $LINEAR_API_URL) overrides this too.
|
|
77
|
+
base_url: str | None = None
|
|
78
|
+
# Team keys this account owns (e.g. ["SWA", "ENG"]). Used for intelligent
|
|
79
|
+
# account auto-selection: a command targeting team SWA (via --team SWA or
|
|
80
|
+
# an identifier like SWA-20) picks the account that lists "SWA" here.
|
|
81
|
+
teams: list[str] = Field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DefaultsConfig(BaseModel):
|
|
85
|
+
"""Default values applied to commands."""
|
|
86
|
+
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
|
|
89
|
+
team: str | None = None
|
|
90
|
+
output: str = "human"
|
|
91
|
+
editor: str | None = None
|
|
92
|
+
default_account: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DisplayConfig(BaseModel):
|
|
96
|
+
"""Display preferences."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(extra="forbid")
|
|
99
|
+
|
|
100
|
+
color: bool = True
|
|
101
|
+
table_max_width: int = 120
|
|
102
|
+
truncate_descriptions: int = 80
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Config(BaseModel):
|
|
106
|
+
"""Top-level Hoja configuration."""
|
|
107
|
+
|
|
108
|
+
model_config = ConfigDict(extra="forbid")
|
|
109
|
+
|
|
110
|
+
# Multi-account support (replaces single [auth] section)
|
|
111
|
+
accounts: dict[str, AccountConfig] = Field(default_factory=dict)
|
|
112
|
+
# Legacy [auth] section — present for loading old configs, migrated to accounts.default
|
|
113
|
+
auth: dict[str, Any] | None = None
|
|
114
|
+
|
|
115
|
+
defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
|
|
116
|
+
display: DisplayConfig = Field(default_factory=DisplayConfig)
|
|
117
|
+
aliases: dict[str, str] = Field(default_factory=dict)
|
|
118
|
+
views: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
119
|
+
# Per-workspace account mappings (absolute repo path → account name)
|
|
120
|
+
workspaces: dict[str, str] = Field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def config_path() -> Path:
|
|
124
|
+
"""Resolve config file location."""
|
|
125
|
+
if env_path := os.environ.get("HOJA_CONFIG"):
|
|
126
|
+
return Path(env_path).expanduser().resolve()
|
|
127
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
128
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
129
|
+
canonical = base / "hoja" / "config.toml"
|
|
130
|
+
if canonical.exists():
|
|
131
|
+
return canonical
|
|
132
|
+
return canonical
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _migrate_legacy_auth(data: dict[str, Any]) -> dict[str, Any]:
|
|
136
|
+
"""Migrate old [auth] section to accounts.default."""
|
|
137
|
+
auth = data.pop("auth", None)
|
|
138
|
+
if auth and isinstance(auth, dict):
|
|
139
|
+
accounts = data.setdefault("accounts", {})
|
|
140
|
+
# Only migrate if accounts.default doesn't already exist
|
|
141
|
+
if "default" not in accounts:
|
|
142
|
+
accounts["default"] = {}
|
|
143
|
+
default = accounts["default"]
|
|
144
|
+
if "token" in auth and "token" not in default:
|
|
145
|
+
default["token"] = auth["token"]
|
|
146
|
+
if "token_env" in auth and "token_env" not in default:
|
|
147
|
+
default["token_env"] = auth["token_env"]
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_config(path: Path | None = None) -> Config:
|
|
152
|
+
"""Load and validate config.toml. Returns defaults if file missing."""
|
|
153
|
+
p = path or config_path()
|
|
154
|
+
if not p.exists():
|
|
155
|
+
return Config()
|
|
156
|
+
try:
|
|
157
|
+
with open(p, "rb") as f:
|
|
158
|
+
data = tomllib.load(f)
|
|
159
|
+
except tomllib.TOMLDecodeError as e:
|
|
160
|
+
raise UsageError(
|
|
161
|
+
f"Invalid TOML in config file: {e}",
|
|
162
|
+
hint=f"Edit {p} to fix syntax",
|
|
163
|
+
) from e
|
|
164
|
+
|
|
165
|
+
# Migrate legacy [auth] section if present
|
|
166
|
+
data = _migrate_legacy_auth(data)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
return Config(**data)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
raise UsageError(
|
|
172
|
+
f"Invalid config: {e}",
|
|
173
|
+
hint="See docs/DESIGN.md for the config schema",
|
|
174
|
+
) from e
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def save_config(config: Config, path: Path | None = None) -> None:
|
|
178
|
+
"""Save config to disk as TOML."""
|
|
179
|
+
if tomli_w is None:
|
|
180
|
+
raise UsageError(
|
|
181
|
+
"Cannot write config: tomli-w not installed",
|
|
182
|
+
hint="pip install tomli-w",
|
|
183
|
+
)
|
|
184
|
+
p = path or config_path()
|
|
185
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
data = _config_to_dict(config)
|
|
187
|
+
with open(p, "wb") as f:
|
|
188
|
+
tomli_w.dump(data, f)
|
|
189
|
+
with contextlib.suppress(OSError):
|
|
190
|
+
os.chmod(p, 0o600)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _config_to_dict(config: Config) -> dict[str, Any]:
|
|
194
|
+
"""Serialize Config to a plain dict for TOML writing."""
|
|
195
|
+
data: dict[str, Any] = {}
|
|
196
|
+
if config.accounts:
|
|
197
|
+
data["accounts"] = {
|
|
198
|
+
name: acc.model_dump(exclude_none=True, exclude_defaults=True)
|
|
199
|
+
for name, acc in config.accounts.items()
|
|
200
|
+
}
|
|
201
|
+
defaults_dict = config.defaults.model_dump(exclude_defaults=True)
|
|
202
|
+
if defaults_dict:
|
|
203
|
+
data["defaults"] = defaults_dict
|
|
204
|
+
display_dict = config.display.model_dump(exclude_defaults=True)
|
|
205
|
+
if display_dict:
|
|
206
|
+
data["display"] = display_dict
|
|
207
|
+
if config.aliases:
|
|
208
|
+
data["aliases"] = config.aliases
|
|
209
|
+
if config.views:
|
|
210
|
+
data["views"] = config.views
|
|
211
|
+
if config.workspaces:
|
|
212
|
+
data["workspaces"] = config.workspaces
|
|
213
|
+
return data
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _find_git_root(start: Path | None = None) -> Path | None:
|
|
217
|
+
"""Walk up from start (or cwd) looking for a .git directory."""
|
|
218
|
+
start = start or Path.cwd()
|
|
219
|
+
path = start.resolve()
|
|
220
|
+
for parent in [path, *path.parents]:
|
|
221
|
+
if (parent / ".git").is_dir():
|
|
222
|
+
return parent
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def resolve_workspace(path: Path | None = None) -> Path | None:
|
|
227
|
+
"""Resolve the current workspace directory (git repo root or None)."""
|
|
228
|
+
return _find_git_root(path)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def team_key_from_hint(hint: str | None) -> str | None:
|
|
232
|
+
"""Extract an uppercase team key from a --team value or an issue identifier.
|
|
233
|
+
|
|
234
|
+
Accepts a bare key ("SWA") or an identifier ("SWA-20") and returns "SWA".
|
|
235
|
+
Returns None for UUIDs, empty values, or anything without a key prefix.
|
|
236
|
+
"""
|
|
237
|
+
if not hint:
|
|
238
|
+
return None
|
|
239
|
+
h = hint.strip()
|
|
240
|
+
# UUIDs (36 chars, 4 dashes) are not team keys.
|
|
241
|
+
if len(h) == 36 and h.count("-") == 4:
|
|
242
|
+
return None
|
|
243
|
+
# Identifier form KEY-123 → KEY
|
|
244
|
+
prefix = h.split("-", 1)[0]
|
|
245
|
+
if prefix.isalpha():
|
|
246
|
+
return prefix.upper()
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def resolve_account(
|
|
251
|
+
cli_account: str | None,
|
|
252
|
+
config: Config,
|
|
253
|
+
workspace_path: Path | None = None,
|
|
254
|
+
team_key: str | None = None,
|
|
255
|
+
) -> tuple[str, AccountConfig]:
|
|
256
|
+
"""Resolve which account to use.
|
|
257
|
+
|
|
258
|
+
Order:
|
|
259
|
+
1. --account CLI flag
|
|
260
|
+
2. Team-key-owning account (account.teams contains team_key)
|
|
261
|
+
3. Workspace-mapped account
|
|
262
|
+
4. Global default account
|
|
263
|
+
5. First available account (fallback)
|
|
264
|
+
6. Not found → synthetic default (lets resolve_token check $HOJA_TOKEN)
|
|
265
|
+
|
|
266
|
+
Returns (account_name, account_config).
|
|
267
|
+
"""
|
|
268
|
+
# 1. CLI flag
|
|
269
|
+
if cli_account:
|
|
270
|
+
if cli_account not in config.accounts:
|
|
271
|
+
raise AuthError(
|
|
272
|
+
f"Account '{cli_account}' not found",
|
|
273
|
+
hint="Run 'hoja auth accounts' to see available accounts.",
|
|
274
|
+
)
|
|
275
|
+
return cli_account, config.accounts[cli_account]
|
|
276
|
+
|
|
277
|
+
# 2. Team-key ownership — pick the account that declares this team key.
|
|
278
|
+
key = team_key_from_hint(team_key)
|
|
279
|
+
if key:
|
|
280
|
+
for name, acc in config.accounts.items():
|
|
281
|
+
if any(t.strip().upper() == key for t in acc.teams):
|
|
282
|
+
return name, acc
|
|
283
|
+
|
|
284
|
+
# 3. Workspace-mapped
|
|
285
|
+
workspace = resolve_workspace(workspace_path)
|
|
286
|
+
if workspace:
|
|
287
|
+
ws_key = str(workspace)
|
|
288
|
+
mapped = config.workspaces.get(ws_key)
|
|
289
|
+
if mapped and mapped in config.accounts:
|
|
290
|
+
return mapped, config.accounts[mapped]
|
|
291
|
+
|
|
292
|
+
# 4. Global default
|
|
293
|
+
if (
|
|
294
|
+
config.defaults.default_account
|
|
295
|
+
and config.defaults.default_account in config.accounts
|
|
296
|
+
):
|
|
297
|
+
return (
|
|
298
|
+
config.defaults.default_account,
|
|
299
|
+
config.accounts[config.defaults.default_account],
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# 5. Fallback to any configured account
|
|
303
|
+
if config.accounts:
|
|
304
|
+
first_name = next(iter(config.accounts))
|
|
305
|
+
return first_name, config.accounts[first_name]
|
|
306
|
+
|
|
307
|
+
# 6. Env-only fallback: no accounts configured but $HOJA_TOKEN (or the
|
|
308
|
+
# deprecated $LINEAR_TOKEN) may exist. Return a synthetic default account so
|
|
309
|
+
# resolve_token can check the env var.
|
|
310
|
+
return "default", AccountConfig(token_env="HOJA_TOKEN")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def resolve_token(
|
|
314
|
+
cli_token: str | None,
|
|
315
|
+
account: AccountConfig,
|
|
316
|
+
) -> str:
|
|
317
|
+
"""Resolve the API token using precedence rules.
|
|
318
|
+
|
|
319
|
+
Order: --token > $<ACCOUNT_TOKEN_ENV> > account.token.
|
|
320
|
+
The default token env var is HOJA_TOKEN; LINEAR_TOKEN is still accepted as
|
|
321
|
+
a deprecated alias when HOJA_TOKEN is unset.
|
|
322
|
+
"""
|
|
323
|
+
if cli_token:
|
|
324
|
+
return cli_token
|
|
325
|
+
env_var = account.token_env or "HOJA_TOKEN"
|
|
326
|
+
if env_token := env_with_legacy(env_var):
|
|
327
|
+
return env_token
|
|
328
|
+
if account.token:
|
|
329
|
+
return account.token
|
|
330
|
+
alias = LEGACY_ENV_ALIASES.get(env_var)
|
|
331
|
+
hint_vars = f"${env_var} (or ${alias})" if alias else f"${env_var}"
|
|
332
|
+
raise AuthError(
|
|
333
|
+
"No Hoja API token found",
|
|
334
|
+
hint=(
|
|
335
|
+
f"Set {hint_vars}, pass --token, or configure token in "
|
|
336
|
+
f"{config_path()}"
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def resolve_base_url(
|
|
342
|
+
cli_base_url: str | None,
|
|
343
|
+
account: AccountConfig,
|
|
344
|
+
) -> str | None:
|
|
345
|
+
"""Resolve the GraphQL endpoint override.
|
|
346
|
+
|
|
347
|
+
Order: --api-url flag > $HOJA_API_URL env (deprecated alias
|
|
348
|
+
$LINEAR_API_URL) > account.base_url > None.
|
|
349
|
+
Returning None means "use the client's default endpoint".
|
|
350
|
+
"""
|
|
351
|
+
if cli_base_url:
|
|
352
|
+
return cli_base_url
|
|
353
|
+
if env_url := env_with_legacy("HOJA_API_URL"):
|
|
354
|
+
return env_url
|
|
355
|
+
return account.base_url
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def redact_token(token: str) -> str:
|
|
359
|
+
"""Show only the last 4 chars of a token for logging."""
|
|
360
|
+
if len(token) <= 8:
|
|
361
|
+
return "***"
|
|
362
|
+
return f"{token[:8]}…{token[-4:]}"
|
hoja/errors.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Error hierarchy and exit codes for hoja.
|
|
2
|
+
|
|
3
|
+
Exit codes are stable across versions — scripts can rely on them.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExitCode(IntEnum):
|
|
13
|
+
"""Stable CLI exit codes. Do not reorder."""
|
|
14
|
+
|
|
15
|
+
SUCCESS = 0
|
|
16
|
+
GENERIC_ERROR = 1
|
|
17
|
+
USAGE_ERROR = 2
|
|
18
|
+
AUTH_ERROR = 3
|
|
19
|
+
NOT_FOUND = 4
|
|
20
|
+
VALIDATION_ERROR = 5
|
|
21
|
+
API_ERROR = 6
|
|
22
|
+
NETWORK_ERROR = 7
|
|
23
|
+
RATE_LIMITED = 8
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HojaError(Exception):
|
|
27
|
+
"""Base class for all hoja errors."""
|
|
28
|
+
|
|
29
|
+
exit_code: ExitCode = ExitCode.GENERIC_ERROR
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.message = message
|
|
34
|
+
self.hint = hint
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, Any]:
|
|
37
|
+
"""Serialize for `-o json` output on error."""
|
|
38
|
+
return {
|
|
39
|
+
"error": {
|
|
40
|
+
"type": self.__class__.__name__,
|
|
41
|
+
"code": int(self.exit_code),
|
|
42
|
+
"message": self.message,
|
|
43
|
+
"hint": self.hint,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuthError(HojaError):
|
|
49
|
+
"""Missing or invalid Linear API token."""
|
|
50
|
+
|
|
51
|
+
exit_code = ExitCode.AUTH_ERROR
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotFoundError(HojaError):
|
|
55
|
+
"""Requested entity does not exist."""
|
|
56
|
+
|
|
57
|
+
exit_code = ExitCode.NOT_FOUND
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ValidationError(HojaError):
|
|
61
|
+
"""Response did not match expected Pydantic model."""
|
|
62
|
+
|
|
63
|
+
exit_code = ExitCode.VALIDATION_ERROR
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class APIError(HojaError):
|
|
67
|
+
"""The GraphQL API returned an error."""
|
|
68
|
+
|
|
69
|
+
exit_code = ExitCode.API_ERROR
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
message: str,
|
|
74
|
+
*,
|
|
75
|
+
errors: list[dict[str, Any]] | None = None,
|
|
76
|
+
hint: str | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
super().__init__(message, hint=hint)
|
|
79
|
+
self.errors = errors or []
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, Any]:
|
|
82
|
+
d = super().to_dict()
|
|
83
|
+
d["error"]["linear_errors"] = self.errors
|
|
84
|
+
return d
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NetworkError(HojaError):
|
|
88
|
+
"""HTTP transport error: timeout, DNS, TLS."""
|
|
89
|
+
|
|
90
|
+
exit_code = ExitCode.NETWORK_ERROR
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RateLimitError(HojaError):
|
|
94
|
+
"""The API rate limit was exceeded."""
|
|
95
|
+
|
|
96
|
+
exit_code = ExitCode.RATE_LIMITED
|
|
97
|
+
|
|
98
|
+
def __init__(self, message: str, *, retry_after: int | None = None) -> None:
|
|
99
|
+
hint = f"Retry after {retry_after} seconds" if retry_after else None
|
|
100
|
+
super().__init__(message, hint=hint)
|
|
101
|
+
self.retry_after = retry_after
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class UsageError(HojaError):
|
|
105
|
+
"""User supplied invalid arguments."""
|
|
106
|
+
|
|
107
|
+
exit_code = ExitCode.USAGE_ERROR
|
hoja/filters.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Filter DSL → Linear's IssueFilter input.
|
|
2
|
+
|
|
3
|
+
Maps friendly CLI flags (--team ENG, --state "In Progress") onto Linear's
|
|
4
|
+
GraphQL filter input shape.
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
- https://linear.app/developers/filtering
|
|
8
|
+
- IssueFilter type in schema/linear-schema.json
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_issue_filter(
|
|
17
|
+
*,
|
|
18
|
+
team: str | None = None,
|
|
19
|
+
state: str | list[str] | None = None,
|
|
20
|
+
not_state: str | list[str] | None = None,
|
|
21
|
+
state_type: str | list[str] | None = None,
|
|
22
|
+
not_state_type: str | list[str] | None = None,
|
|
23
|
+
assignee: str | None = None,
|
|
24
|
+
creator: str | None = None,
|
|
25
|
+
project: str | None = None,
|
|
26
|
+
cycle: str | None = None,
|
|
27
|
+
label: str | list[str] | None = None,
|
|
28
|
+
priority: int | None = None,
|
|
29
|
+
contains: str | None = None,
|
|
30
|
+
updated_after: str | None = None,
|
|
31
|
+
updated_before: str | None = None,
|
|
32
|
+
due_before: str | None = None,
|
|
33
|
+
due_after: str | None = None,
|
|
34
|
+
viewer_id: str | None = None,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Build an IssueFilter dict from CLI flags.
|
|
37
|
+
|
|
38
|
+
`viewer_id` is required if any field resolves to "me".
|
|
39
|
+
"""
|
|
40
|
+
f: dict[str, Any] = {}
|
|
41
|
+
|
|
42
|
+
if team:
|
|
43
|
+
# Accept either team key (ENG) or team UUID
|
|
44
|
+
if _looks_like_uuid(team):
|
|
45
|
+
f["team"] = {"id": {"eq": team}}
|
|
46
|
+
else:
|
|
47
|
+
f["team"] = {"key": {"eq": team.upper()}}
|
|
48
|
+
|
|
49
|
+
if state is not None:
|
|
50
|
+
f["state"] = _str_or_list_filter("name", state)
|
|
51
|
+
if not_state is not None:
|
|
52
|
+
f.setdefault("state", {}).update(_str_or_list_negate("name", not_state))
|
|
53
|
+
|
|
54
|
+
if state_type is not None:
|
|
55
|
+
f["state"] = {**f.get("state", {}), **_str_or_list_filter("type", state_type)}
|
|
56
|
+
if not_state_type is not None:
|
|
57
|
+
f["state"] = {**f.get("state", {}), **_str_or_list_negate("type", not_state_type)}
|
|
58
|
+
|
|
59
|
+
if assignee:
|
|
60
|
+
f["assignee"] = _user_subfilter(assignee, viewer_id)
|
|
61
|
+
|
|
62
|
+
if creator:
|
|
63
|
+
f["creator"] = _user_subfilter(creator, viewer_id)
|
|
64
|
+
|
|
65
|
+
if project:
|
|
66
|
+
if _looks_like_uuid(project):
|
|
67
|
+
f["project"] = {"id": {"eq": project}}
|
|
68
|
+
else:
|
|
69
|
+
f["project"] = {"name": {"containsIgnoreCase": project}}
|
|
70
|
+
|
|
71
|
+
if cycle:
|
|
72
|
+
if cycle.lower() == "current":
|
|
73
|
+
# Linear supports cycle: { isActive: { eq: true } } — but only via Team scope
|
|
74
|
+
# For simplicity expose via cycle filter using number from the resolved cycle id
|
|
75
|
+
f["cycle"] = {"isActive": {"eq": True}}
|
|
76
|
+
elif cycle.lower() == "next":
|
|
77
|
+
f["cycle"] = {"isNext": {"eq": True}}
|
|
78
|
+
elif _looks_like_uuid(cycle):
|
|
79
|
+
f["cycle"] = {"id": {"eq": cycle}}
|
|
80
|
+
else:
|
|
81
|
+
f["cycle"] = {"name": {"containsIgnoreCase": cycle}}
|
|
82
|
+
|
|
83
|
+
if label is not None:
|
|
84
|
+
f["labels"] = {"name": _str_or_list_op(label)}
|
|
85
|
+
|
|
86
|
+
if priority is not None:
|
|
87
|
+
f["priority"] = {"eq": priority}
|
|
88
|
+
|
|
89
|
+
if contains:
|
|
90
|
+
# Linear's free-text on issue is title/description — use `or`
|
|
91
|
+
f["or"] = [
|
|
92
|
+
{"title": {"containsIgnoreCase": contains}},
|
|
93
|
+
{"description": {"containsIgnoreCase": contains}},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
if updated_after:
|
|
97
|
+
f["updatedAt"] = {**f.get("updatedAt", {}), "gt": updated_after}
|
|
98
|
+
if updated_before:
|
|
99
|
+
f["updatedAt"] = {**f.get("updatedAt", {}), "lt": updated_before}
|
|
100
|
+
|
|
101
|
+
if due_after:
|
|
102
|
+
f["dueDate"] = {**f.get("dueDate", {}), "gt": due_after}
|
|
103
|
+
if due_before:
|
|
104
|
+
f["dueDate"] = {**f.get("dueDate", {}), "lt": due_before}
|
|
105
|
+
|
|
106
|
+
return f
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _looks_like_uuid(value: str) -> bool:
|
|
110
|
+
"""Cheap UUID detector — Linear IDs are 36-char UUIDs with hyphens."""
|
|
111
|
+
return len(value) == 36 and value.count("-") == 4
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _str_or_list_op(value: str | list[str]) -> dict[str, Any]:
|
|
115
|
+
"""Produce eq/in clause depending on type."""
|
|
116
|
+
if isinstance(value, str):
|
|
117
|
+
if "," in value:
|
|
118
|
+
return {"in": [v.strip() for v in value.split(",")]}
|
|
119
|
+
return {"eq": value}
|
|
120
|
+
return {"in": value}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _str_or_list_filter(field: str, value: str | list[str]) -> dict[str, Any]:
|
|
124
|
+
return {field: _str_or_list_op(value)}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _str_or_list_negate(field: str, value: str | list[str]) -> dict[str, Any]:
|
|
128
|
+
op = _str_or_list_op(value)
|
|
129
|
+
if "eq" in op:
|
|
130
|
+
return {field: {"neq": op["eq"]}}
|
|
131
|
+
if "in" in op:
|
|
132
|
+
return {field: {"nin": op["in"]}}
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _user_subfilter(value: str, viewer_id: str | None) -> dict[str, Any]:
|
|
137
|
+
"""Resolve 'me', a UUID, or an email to a Linear user filter."""
|
|
138
|
+
v = value.strip()
|
|
139
|
+
if v.lower() == "me":
|
|
140
|
+
if not viewer_id:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"Used 'me' as assignee/creator but viewer hasn't been resolved"
|
|
143
|
+
)
|
|
144
|
+
return {"id": {"eq": viewer_id}}
|
|
145
|
+
if _looks_like_uuid(v):
|
|
146
|
+
return {"id": {"eq": v}}
|
|
147
|
+
if "@" in v:
|
|
148
|
+
return {"email": {"eq": v}}
|
|
149
|
+
# Fall back: display name contains
|
|
150
|
+
return {"displayName": {"containsIgnoreCase": v}}
|