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/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}}
@@ -0,0 +1,5 @@
1
+ """GraphQL query/mutation strings and fragments.
2
+
3
+ We keep raw GraphQL here rather than codegen so dependencies stay minimal
4
+ and queries stay readable/auditable.
5
+ """