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
@@ -0,0 +1,64 @@
1
+ """Read-only connector operations. Each takes a verified Principal and resolves accounts from the database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+
9
+ import httpx
10
+
11
+ from google_workspace.client import (
12
+ CalendarEvent,
13
+ Document,
14
+ DriveFile,
15
+ GmailMessage,
16
+ GoogleWorkspaceClient,
17
+ )
18
+ from google_workspace.errors import ConnectorAuthError
19
+ from google_workspace.principal import Principal
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AccountGrant:
24
+ """A connected account the principal may use. `access_token` returns a fresh token."""
25
+
26
+ account_id: str
27
+ external_account: str
28
+ access_token: Callable[[], Awaitable[str]]
29
+
30
+
31
+ async def _client_for(http: httpx.AsyncClient, grant: AccountGrant) -> GoogleWorkspaceClient:
32
+ token = await grant.access_token()
33
+ if not token:
34
+ raise ConnectorAuthError(
35
+ "Google account has no usable access token.", hint="Reconnect the account."
36
+ )
37
+ return GoogleWorkspaceClient(http, token)
38
+
39
+
40
+ async def gmail_search(
41
+ http: httpx.AsyncClient, grant: AccountGrant, query: str, max_results: int = 10
42
+ ) -> list[GmailMessage]:
43
+ return await (await _client_for(http, grant)).gmail_search(query, max_results)
44
+
45
+
46
+ async def drive_search(
47
+ http: httpx.AsyncClient, grant: AccountGrant, query: str, max_results: int = 10
48
+ ) -> list[DriveFile]:
49
+ return await (await _client_for(http, grant)).drive_search(query, max_results)
50
+
51
+
52
+ async def calendar_events(
53
+ http: httpx.AsyncClient, grant: AccountGrant, start: datetime, end: datetime
54
+ ) -> list[CalendarEvent]:
55
+ return await (await _client_for(http, grant)).calendar_events(start, end)
56
+
57
+
58
+ async def docs_get(http: httpx.AsyncClient, grant: AccountGrant, document_id: str) -> Document:
59
+ return await (await _client_for(http, grant)).docs_get(document_id)
60
+
61
+
62
+ def require_read(principal: Principal) -> None:
63
+ if principal.scope not in {"read", "write"}:
64
+ raise ConnectorAuthError("Principal lacks read scope.")
@@ -0,0 +1,44 @@
1
+ """Write operations available through the approval queue. Only the names in WRITE_TOOLS can run,
2
+ and each one validates its arguments before any request reaches Google."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from google_workspace.client import GoogleWorkspaceClient
12
+ from google_workspace.tools import AccountGrant
13
+
14
+ WRITE_TOOLS = ("gmail_send", "calendar_event_create", "docs_append_text")
15
+
16
+
17
+ def _str(args: dict[str, Any], key: str, limit: int) -> str:
18
+ value = args.get(key)
19
+ if not isinstance(value, str) or not value.strip() or len(value) > limit:
20
+ raise ValueError(f"{key} must be a non-empty string up to {limit} characters.")
21
+ return value
22
+
23
+
24
+ async def run_write(
25
+ http: httpx.AsyncClient, grant: AccountGrant, tool: str, args: dict[str, Any]
26
+ ) -> str:
27
+ """Execute one approved write. Returns a short, non-sensitive summary for the audit row."""
28
+ if tool not in WRITE_TOOLS:
29
+ raise ValueError(f"Unknown write tool: {tool}")
30
+ client = GoogleWorkspaceClient(http, await grant.access_token())
31
+ if tool == "gmail_send":
32
+ message_id = await client.gmail_send(
33
+ to=_str(args, "to", 320),
34
+ subject=_str(args, "subject", 200),
35
+ body=_str(args, "body", 20000),
36
+ )
37
+ return f"sent message {message_id}"
38
+ if tool == "calendar_event_create":
39
+ start = datetime.fromisoformat(_str(args, "start", 64))
40
+ end = datetime.fromisoformat(_str(args, "end", 64))
41
+ event_id = await client.calendar_event_create(_str(args, "summary", 200), start, end)
42
+ return f"created event {event_id}"
43
+ await client.docs_append_text(_str(args, "document_id", 200), _str(args, "text", 20000))
44
+ return "appended text"
hoja/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Hoja — self-hosted, agent-native work management."""
2
+
3
+ from pathlib import Path
4
+
5
+ #: PyPI distribution that ships this package. The project cannot be named
6
+ #: ``hoja`` on PyPI (that name belongs to unrelated software), so it is
7
+ #: published as ``hoja-cli``. Anything that talks to PyPI — the ``hoja update``
8
+ #: command, install instructions, release tooling — must use this name. The
9
+ #: commands stay ``hoja``, ``hoja-mcp``, and ``hoja-serve``.
10
+ DISTRIBUTION_NAME = "hoja-cli"
11
+
12
+
13
+ def _read_version() -> str:
14
+ """Read version from VERSION file at the repo root.
15
+
16
+ Falls back to a hardcoded string if the file is missing (e.g. when
17
+ installed from a wheel where VERSION isn't packaged).
18
+ """
19
+ try:
20
+ version_file = Path(__file__).resolve().parent.parent / "VERSION"
21
+ if version_file.is_file():
22
+ return version_file.read_text(encoding="utf-8").strip()
23
+ except OSError:
24
+ pass
25
+ return "0.7.0"
26
+
27
+
28
+ __version__ = _read_version()
29
+ __all__ = ["DISTRIBUTION_NAME", "__version__"]
hoja/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m hoja`."""
2
+
3
+ from hoja.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
hoja/auth.py ADDED
@@ -0,0 +1,42 @@
1
+ """Authentication helpers.
2
+
3
+ Token resolution lives in `config.py`. This module wraps viewer caching
4
+ so we can resolve "me" without an extra API call when the user passes
5
+ `--assignee me`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ from hoja.client import LinearClient
13
+ from hoja.graphql import queries
14
+ from hoja.models.user import User
15
+
16
+ _viewer_cache: User | None = None
17
+ _viewer_lock = asyncio.Lock()
18
+
19
+
20
+ async def get_viewer(client: LinearClient, *, refresh: bool = False) -> User:
21
+ """Return the currently-authenticated user.
22
+
23
+ Cached per-process so repeated --assignee me resolutions don't
24
+ cost an API call each.
25
+ """
26
+ global _viewer_cache
27
+ if _viewer_cache is not None and not refresh:
28
+ return _viewer_cache
29
+
30
+ async with _viewer_lock:
31
+ if _viewer_cache is not None and not refresh:
32
+ return _viewer_cache
33
+ _viewer_cache = await client.execute_as(
34
+ User, queries.VIEWER, path=["viewer"], operation="Viewer"
35
+ )
36
+ return _viewer_cache
37
+
38
+
39
+ def reset_viewer_cache() -> None:
40
+ """Reset cached viewer (used after logout/token change)."""
41
+ global _viewer_cache
42
+ _viewer_cache = None
hoja/cli.py ADDED
@@ -0,0 +1,161 @@
1
+ """Root CLI entrypoint.
2
+
3
+ Defines the top-level Typer app, global flags, error handling, and
4
+ registers all subcommand modules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from typing import Optional
11
+
12
+ import typer
13
+
14
+ from hoja import DISTRIBUTION_NAME, __version__
15
+ from hoja.cli_state import CLIState, set_state
16
+ from hoja.commands.auth_cmd import auth_app, me_app
17
+ from hoja.commands.comment import comment_app
18
+ from hoja.commands.cycle import cycle_app
19
+ from hoja.commands.init import init_app
20
+ from hoja.commands.issue import issue_app
21
+ from hoja.commands.label import label_app
22
+ from hoja.commands.memory import memory_app
23
+ from hoja.commands.project import project_app
24
+ from hoja.commands.raw import raw_app
25
+ from hoja.commands.team import team_app
26
+ from hoja.commands.update import update_app
27
+ from hoja.config import load_config, resolve_account, resolve_base_url, resolve_token
28
+ from hoja.errors import HojaError
29
+ from hoja.models.enums import OutputFormat
30
+ from hoja.output import emit_error
31
+
32
+ app = typer.Typer(
33
+ name="hoja",
34
+ help=(
35
+ "Hoja CLI — agent-native work management for Hoja and Linear-compatible "
36
+ f"services (installed from the PyPI package {DISTRIBUTION_NAME})"
37
+ ),
38
+ no_args_is_help=True,
39
+ pretty_exceptions_enable=False,
40
+ )
41
+
42
+
43
+ def _version_cb(value: bool) -> None:
44
+ if value:
45
+ typer.echo(f"hoja {__version__} (PyPI: {DISTRIBUTION_NAME})")
46
+ raise typer.Exit()
47
+
48
+
49
+ def _team_key_hint_from_argv(argv: list[str]) -> str | None:
50
+ """Best-effort scan of argv for a team key, used for account selection.
51
+
52
+ The root callback runs before subcommands parse their own options, so we
53
+ sniff argv for either a ``--team/-t <value>`` option or a bare issue
54
+ identifier like ``SWA-20``. This is purely a hint — it only influences
55
+ which account (token) is chosen, never what the subcommand does.
56
+ """
57
+ for i, arg in enumerate(argv):
58
+ if arg in ("--team", "-t") and i + 1 < len(argv):
59
+ return argv[i + 1]
60
+ if arg.startswith("--team="):
61
+ return arg.split("=", 1)[1]
62
+ # Fall back to the first issue-identifier-looking positional (KEY-123).
63
+ for arg in argv:
64
+ if arg.startswith("-"):
65
+ continue
66
+ head, sep, tail = arg.partition("-")
67
+ if sep and head.isalpha() and tail.isdigit():
68
+ return arg
69
+ return None
70
+
71
+
72
+ @app.callback()
73
+ def main(
74
+ ctx: typer.Context,
75
+ token: Optional[str] = typer.Option(None, "--token", envvar=None, help="Hoja API token (overrides $HOJA_TOKEN)"),
76
+ account: Optional[str] = typer.Option(None, "--account", "-a", envvar=None, help="Account name to use (overrides workspace/default)"),
77
+ output: OutputFormat = typer.Option(OutputFormat.HUMAN, "--output", "-o", help="Output format"),
78
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Print GraphQL operations to stderr"),
79
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-essential output"),
80
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors"),
81
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print mutations without executing"),
82
+ timeout: float = typer.Option(30.0, "--timeout", help="HTTP timeout in seconds"),
83
+ version: Optional[bool] = typer.Option(
84
+ None, "--version", callback=_version_cb, is_eager=True, help="Show version and exit"
85
+ ),
86
+ ) -> None:
87
+ """Global flags applied before any subcommand."""
88
+ # Resolve config + account + token
89
+ config = load_config()
90
+
91
+ # Account resolution: only required for commands that hit the API.
92
+ # We defer the actual AuthError to first API call to allow `--help` etc.
93
+ try:
94
+ team_hint = _team_key_hint_from_argv(sys.argv[1:])
95
+ account_name, account_cfg = resolve_account(
96
+ account, config, team_key=team_hint
97
+ )
98
+ resolved_token = resolve_token(token, account_cfg)
99
+ resolved_base_url = resolve_base_url(None, account_cfg)
100
+ except HojaError:
101
+ account_name = ""
102
+ resolved_token = ""
103
+ resolved_base_url = None
104
+
105
+ state = CLIState(
106
+ token=resolved_token,
107
+ config=config,
108
+ output=output,
109
+ verbose=verbose,
110
+ quiet=quiet,
111
+ no_color=no_color,
112
+ dry_run=dry_run,
113
+ timeout=timeout,
114
+ account_name=account_name,
115
+ base_url=resolved_base_url,
116
+ )
117
+ set_state(state)
118
+
119
+
120
+ # Register subcommands
121
+ app.add_typer(me_app, name="me")
122
+ app.add_typer(auth_app, name="auth")
123
+ app.add_typer(init_app, name="init")
124
+ app.add_typer(team_app, name="team")
125
+ app.add_typer(issue_app, name="issue")
126
+ app.add_typer(project_app, name="project")
127
+ app.add_typer(cycle_app, name="cycle")
128
+ app.add_typer(comment_app, name="comment")
129
+ app.add_typer(label_app, name="label")
130
+ app.add_typer(memory_app, name="memory")
131
+ app.add_typer(raw_app, name="raw")
132
+ app.add_typer(update_app, name="update")
133
+
134
+
135
+ def _entry() -> None:
136
+ """Wrap typer_app() with consistent error handling and exit codes."""
137
+ try:
138
+ typer_app()
139
+ except HojaError as e:
140
+ from hoja.cli_state import get_state
141
+ state = get_state()
142
+ if state.output == OutputFormat.JSON:
143
+ import json as _json
144
+ print(_json.dumps(e.to_dict(), indent=2), file=sys.stderr)
145
+ else:
146
+ emit_error(e.message, hint=e.hint)
147
+ sys.exit(int(e.exit_code))
148
+ except KeyboardInterrupt:
149
+ emit_error("Interrupted")
150
+ sys.exit(130)
151
+
152
+
153
+ # Public callable for `hoja` script entry point in pyproject.toml.
154
+ # Renamed to `typer_app` internally so the wrapper is what pip-installed
155
+ # `hoja` actually invokes.
156
+ typer_app = app
157
+ app = _entry # type: ignore[assignment]
158
+
159
+
160
+ if __name__ == "__main__":
161
+ _entry()
hoja/cli_state.py ADDED
@@ -0,0 +1,55 @@
1
+ """Shared CLI state — populated by the root callback, read by subcommands.
2
+
3
+ Typer doesn't have a great way to share state across commands without going
4
+ through Context. We use a small module-level container to keep subcommands
5
+ clean.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from hoja.client import LinearClient
13
+ from hoja.config import Config
14
+ from hoja.models.enums import OutputFormat
15
+
16
+
17
+ @dataclass
18
+ class CLIState:
19
+ """Mutable per-invocation state."""
20
+
21
+ token: str = ""
22
+ config: Config = field(default_factory=Config)
23
+ output: OutputFormat = OutputFormat.HUMAN
24
+ verbose: bool = False
25
+ quiet: bool = False
26
+ no_color: bool = False
27
+ dry_run: bool = False
28
+ timeout: float = 30.0
29
+ account_name: str = "" # active account name for context display
30
+ base_url: str | None = None # GraphQL endpoint override (local backend)
31
+
32
+
33
+ _state = CLIState()
34
+
35
+
36
+ def get_state() -> CLIState:
37
+ return _state
38
+
39
+
40
+ def set_state(state: CLIState) -> None:
41
+ global _state
42
+ _state = state
43
+
44
+
45
+ def build_client(state: CLIState | None = None) -> LinearClient:
46
+ """Construct a LinearClient configured from the current CLI state."""
47
+ s = state or _state
48
+ kwargs = dict(
49
+ token=s.token,
50
+ timeout=s.timeout,
51
+ verbose=s.verbose,
52
+ )
53
+ if s.base_url:
54
+ kwargs["base_url"] = s.base_url
55
+ return LinearClient(**kwargs)
hoja/client.py ADDED
@@ -0,0 +1,268 @@
1
+ """Async GraphQL client for Hoja's Linear-compatible GraphQL API.
2
+
3
+ Design goals:
4
+ - Single httpx.AsyncClient with sensible defaults (HTTP/2, timeout, retries).
5
+ - Token never logged in plaintext (redacted in verbose mode).
6
+ - GraphQL errors lifted into typed exceptions.
7
+ - Rate limit awareness (X-RateLimit-* headers honored).
8
+ - Cursor-based pagination helper.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from typing import Any, AsyncIterator, TypeVar
16
+
17
+ import httpx
18
+ from pydantic import BaseModel
19
+
20
+ from hoja import __version__
21
+ from hoja.config import redact_token
22
+ from hoja.errors import (
23
+ APIError,
24
+ AuthError,
25
+ NetworkError,
26
+ RateLimitError,
27
+ ValidationError,
28
+ )
29
+
30
+ logger = logging.getLogger("hoja.client")
31
+
32
+ # Endpoint used when no --api-url flag, $HOJA_API_URL (or deprecated
33
+ # $LINEAR_API_URL), or accounts.<name>.base_url is configured. Point the client
34
+ # at your own `hoja-serve` instance (e.g. http://127.0.0.1:8787/graphql) to stay
35
+ # fully self-hosted; this fallback is the public Linear-compatible API.
36
+ DEFAULT_API_URL = "https://api.linear.app/graphql"
37
+
38
+ T = TypeVar("T", bound=BaseModel)
39
+
40
+
41
+ class LinearClient:
42
+ """Async GraphQL client for a Linear-compatible GraphQL API."""
43
+
44
+ def __init__(
45
+ self,
46
+ token: str,
47
+ *,
48
+ base_url: str = DEFAULT_API_URL,
49
+ timeout: float = 30.0,
50
+ verbose: bool = False,
51
+ ) -> None:
52
+ self._token = token
53
+ self._base_url = base_url
54
+ self._verbose = verbose
55
+ self._http = httpx.AsyncClient(
56
+ http2=False, # httpx[http2] is a separate dep — keep deps minimal
57
+ timeout=timeout,
58
+ headers={
59
+ "Authorization": token,
60
+ "Content-Type": "application/json",
61
+ "User-Agent": f"hoja/{__version__} (+https://github.com/rinadelph/hoja)",
62
+ },
63
+ )
64
+
65
+ async def __aenter__(self) -> "LinearClient":
66
+ return self
67
+
68
+ async def __aexit__(self, *_: Any) -> None:
69
+ await self.close()
70
+
71
+ async def close(self) -> None:
72
+ await self._http.aclose()
73
+
74
+ # ------------------------------------------------------------------
75
+ # Core request method
76
+ # ------------------------------------------------------------------
77
+
78
+ async def execute(
79
+ self,
80
+ query: str,
81
+ variables: dict[str, Any] | None = None,
82
+ *,
83
+ operation: str | None = None,
84
+ ) -> dict[str, Any]:
85
+ """Execute a GraphQL query/mutation. Returns the `data` portion.
86
+
87
+ Raises:
88
+ AuthError: 401
89
+ RateLimitError: 429
90
+ APIError: GraphQL errors in response
91
+ NetworkError: transport-level failures
92
+ """
93
+ payload: dict[str, Any] = {"query": query}
94
+ if variables:
95
+ # Drop None values — Linear's API rejects explicit nulls in many inputs
96
+ payload["variables"] = _strip_none(variables)
97
+ if operation:
98
+ payload["operationName"] = operation
99
+
100
+ if self._verbose:
101
+ logger.info(
102
+ "POST %s [token=%s] op=%s vars=%s",
103
+ self._base_url,
104
+ redact_token(self._token),
105
+ operation or "<anon>",
106
+ json.dumps(payload.get("variables", {}), default=str)[:200],
107
+ )
108
+
109
+ try:
110
+ resp = await self._http.post(self._base_url, json=payload)
111
+ except httpx.TimeoutException as e:
112
+ raise NetworkError(f"Request timed out: {e}") from e
113
+ except httpx.RequestError as e:
114
+ raise NetworkError(f"Network error: {e}") from e
115
+
116
+ # Handle HTTP-level errors
117
+ if resp.status_code == 401:
118
+ raise AuthError(
119
+ "The API rejected the token (HTTP 401)",
120
+ hint="Mint a new token in your workspace, or check $HOJA_TOKEN",
121
+ )
122
+ if resp.status_code == 429:
123
+ retry = resp.headers.get("Retry-After")
124
+ raise RateLimitError(
125
+ "API rate limit exceeded",
126
+ retry_after=int(retry) if retry and retry.isdigit() else None,
127
+ )
128
+ if resp.status_code >= 500:
129
+ raise APIError(
130
+ f"The API returned HTTP {resp.status_code}",
131
+ hint="This is usually transient — retry in a few seconds",
132
+ )
133
+ if resp.status_code >= 400:
134
+ raise APIError(
135
+ f"HTTP {resp.status_code}: {resp.text[:500]}",
136
+ )
137
+
138
+ try:
139
+ body = resp.json()
140
+ except json.JSONDecodeError as e:
141
+ raise APIError(f"Non-JSON response from Linear: {e}") from e
142
+
143
+ # GraphQL-level errors
144
+ if errors := body.get("errors"):
145
+ messages = "; ".join(e.get("message", "<unknown>") for e in errors)
146
+ raise APIError(messages, errors=errors)
147
+
148
+ data = body.get("data")
149
+ if data is None:
150
+ raise APIError("Linear returned empty data")
151
+ return data
152
+
153
+ # ------------------------------------------------------------------
154
+ # Typed helpers
155
+ # ------------------------------------------------------------------
156
+
157
+ async def execute_as(
158
+ self,
159
+ model: type[T],
160
+ query: str,
161
+ variables: dict[str, Any] | None = None,
162
+ *,
163
+ path: list[str] | None = None,
164
+ operation: str | None = None,
165
+ ) -> T:
166
+ """Execute and validate the response against a Pydantic model.
167
+
168
+ path: list of dict keys to drill down to before parsing.
169
+ e.g. ["viewer"] turns {"viewer": {...}} into the viewer object.
170
+ """
171
+ data = await self.execute(query, variables, operation=operation)
172
+ node: Any = data
173
+ for key in path or []:
174
+ if not isinstance(node, dict) or key not in node:
175
+ raise ValidationError(
176
+ f"Expected key {key!r} in response path "
177
+ f"{'.'.join(path or [])}",
178
+ )
179
+ node = node[key]
180
+ try:
181
+ return model.model_validate(node)
182
+ except Exception as e:
183
+ raise ValidationError(
184
+ f"Response did not match {model.__name__}: {e}",
185
+ ) from e
186
+
187
+ async def execute_list(
188
+ self,
189
+ model: type[T],
190
+ query: str,
191
+ variables: dict[str, Any] | None = None,
192
+ *,
193
+ path: list[str],
194
+ operation: str | None = None,
195
+ ) -> list[T]:
196
+ """Execute and validate a connection.nodes list response.
197
+
198
+ path should end at the dict containing 'nodes'.
199
+ """
200
+ data = await self.execute(query, variables, operation=operation)
201
+ node: Any = data
202
+ for key in path:
203
+ if not isinstance(node, dict) or key not in node:
204
+ raise ValidationError(
205
+ f"Expected key {key!r} at path {'.'.join(path)}",
206
+ )
207
+ node = node[key]
208
+ if not isinstance(node, dict) or "nodes" not in node:
209
+ raise ValidationError(
210
+ "Expected a connection object with 'nodes' at path "
211
+ f"{'.'.join(path)}"
212
+ )
213
+ try:
214
+ return [model.model_validate(item) for item in node["nodes"]]
215
+ except Exception as e:
216
+ raise ValidationError(
217
+ f"List items did not match {model.__name__}: {e}",
218
+ ) from e
219
+
220
+ # ------------------------------------------------------------------
221
+ # Pagination
222
+ # ------------------------------------------------------------------
223
+
224
+ async def paginate(
225
+ self,
226
+ query: str,
227
+ variables: dict[str, Any],
228
+ *,
229
+ path: list[str],
230
+ page_size: int = 50,
231
+ ) -> AsyncIterator[dict[str, Any]]:
232
+ """Yield every node across all pages of a connection."""
233
+ cursor: str | None = None
234
+ while True:
235
+ vars_with_cursor = {**variables, "first": page_size}
236
+ if cursor:
237
+ vars_with_cursor["after"] = cursor
238
+ data = await self.execute(query, vars_with_cursor)
239
+ node: Any = data
240
+ for key in path:
241
+ node = node[key]
242
+ for item in node.get("nodes", []):
243
+ yield item
244
+ page_info = node.get("pageInfo") or {}
245
+ if not page_info.get("hasNextPage"):
246
+ break
247
+ cursor = page_info.get("endCursor")
248
+ if not cursor:
249
+ break
250
+
251
+
252
+ def _strip_none(d: dict[str, Any]) -> dict[str, Any]:
253
+ """Recursively remove None values from a dict for GraphQL variables."""
254
+ if not isinstance(d, dict):
255
+ return d
256
+ out: dict[str, Any] = {}
257
+ for k, v in d.items():
258
+ if v is None:
259
+ continue
260
+ if isinstance(v, dict):
261
+ stripped = _strip_none(v)
262
+ if stripped:
263
+ out[k] = stripped
264
+ elif isinstance(v, list):
265
+ out[k] = [_strip_none(item) if isinstance(item, dict) else item for item in v]
266
+ else:
267
+ out[k] = v
268
+ return out
@@ -0,0 +1,4 @@
1
+ """Command modules.
2
+
3
+ Each file exports a Typer sub-app registered in hoja.cli.
4
+ """