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,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
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
|