clinear 0.2.0__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
- clinear/__init__.py +22 -0
- clinear/__main__.py +6 -0
- clinear/auth.py +43 -0
- clinear/cli.py +122 -0
- clinear/cli_state.py +50 -0
- clinear/client.py +265 -0
- clinear/commands/__init__.py +4 -0
- clinear/commands/auth_cmd.py +42 -0
- clinear/commands/comment.py +149 -0
- clinear/commands/cycle.py +75 -0
- clinear/commands/init.py +84 -0
- clinear/commands/issue.py +366 -0
- clinear/commands/label.py +163 -0
- clinear/commands/project.py +57 -0
- clinear/commands/raw.py +50 -0
- clinear/commands/team.py +102 -0
- clinear/config.py +131 -0
- clinear/errors.py +107 -0
- clinear/filters.py +150 -0
- clinear/graphql/__init__.py +5 -0
- clinear/graphql/fragments.py +137 -0
- clinear/graphql/mutations.py +160 -0
- clinear/graphql/queries.py +245 -0
- clinear/models/__init__.py +37 -0
- clinear/models/base.py +64 -0
- clinear/models/cycle.py +32 -0
- clinear/models/enums.py +62 -0
- clinear/models/issue.py +99 -0
- clinear/models/label.py +17 -0
- clinear/models/project.py +60 -0
- clinear/models/team.py +30 -0
- clinear/models/user.py +32 -0
- clinear/models/workflow.py +36 -0
- clinear/output.py +412 -0
- clinear-0.2.0.dist-info/METADATA +234 -0
- clinear-0.2.0.dist-info/RECORD +40 -0
- clinear-0.2.0.dist-info/WHEEL +4 -0
- clinear-0.2.0.dist-info/entry_points.txt +2 -0
- clinear-0.2.0.dist-info/licenses/LICENSE +21 -0
clinear/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""clinear — Type-safe Linear CLI built on Pydantic v2."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _read_version() -> str:
|
|
7
|
+
"""Read version from VERSION file at the repo root.
|
|
8
|
+
|
|
9
|
+
Falls back to a hardcoded string if the file is missing (e.g. when
|
|
10
|
+
installed from a wheel where VERSION isn't packaged).
|
|
11
|
+
"""
|
|
12
|
+
try:
|
|
13
|
+
version_file = Path(__file__).resolve().parent.parent / "VERSION"
|
|
14
|
+
if version_file.is_file():
|
|
15
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
16
|
+
except OSError:
|
|
17
|
+
pass
|
|
18
|
+
return "0.2.0"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__version__ = _read_version()
|
|
22
|
+
__all__ = ["__version__"]
|
clinear/__main__.py
ADDED
clinear/auth.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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 clinear.client import LinearClient
|
|
13
|
+
from clinear.graphql import queries
|
|
14
|
+
from clinear.models.user import User
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_viewer_cache: User | None = None
|
|
18
|
+
_viewer_lock = asyncio.Lock()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def get_viewer(client: LinearClient, *, refresh: bool = False) -> User:
|
|
22
|
+
"""Return the currently-authenticated user.
|
|
23
|
+
|
|
24
|
+
Cached per-process so repeated --assignee me resolutions don't
|
|
25
|
+
cost an API call each.
|
|
26
|
+
"""
|
|
27
|
+
global _viewer_cache
|
|
28
|
+
if _viewer_cache is not None and not refresh:
|
|
29
|
+
return _viewer_cache
|
|
30
|
+
|
|
31
|
+
async with _viewer_lock:
|
|
32
|
+
if _viewer_cache is not None and not refresh:
|
|
33
|
+
return _viewer_cache
|
|
34
|
+
_viewer_cache = await client.execute_as(
|
|
35
|
+
User, queries.VIEWER, path=["viewer"], operation="Viewer"
|
|
36
|
+
)
|
|
37
|
+
return _viewer_cache
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reset_viewer_cache() -> None:
|
|
41
|
+
"""Reset cached viewer (used after logout/token change)."""
|
|
42
|
+
global _viewer_cache
|
|
43
|
+
_viewer_cache = None
|
clinear/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
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 clinear import __version__
|
|
15
|
+
from clinear.cli_state import CLIState, set_state
|
|
16
|
+
from clinear.commands.auth_cmd import auth_app, me_app
|
|
17
|
+
from clinear.commands.comment import comment_app
|
|
18
|
+
from clinear.commands.cycle import cycle_app
|
|
19
|
+
from clinear.commands.init import init_app
|
|
20
|
+
from clinear.commands.issue import issue_app
|
|
21
|
+
from clinear.commands.label import label_app
|
|
22
|
+
from clinear.commands.project import project_app
|
|
23
|
+
from clinear.commands.raw import raw_app
|
|
24
|
+
from clinear.commands.team import team_app
|
|
25
|
+
from clinear.config import load_config, resolve_token
|
|
26
|
+
from clinear.errors import ClinearError
|
|
27
|
+
from clinear.models.enums import OutputFormat
|
|
28
|
+
from clinear.output import emit_error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="clinear",
|
|
33
|
+
help="Type-safe Linear CLI built on Pydantic v2 + httpx + Typer",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
pretty_exceptions_enable=False,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _version_cb(value: bool) -> None:
|
|
40
|
+
if value:
|
|
41
|
+
typer.echo(f"clinear {__version__}")
|
|
42
|
+
raise typer.Exit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def main(
|
|
47
|
+
ctx: typer.Context,
|
|
48
|
+
token: Optional[str] = typer.Option(None, "--token", envvar=None, help="Linear API token (overrides $LINEAR_TOKEN)"),
|
|
49
|
+
output: OutputFormat = typer.Option(OutputFormat.HUMAN, "--output", "-o", help="Output format"),
|
|
50
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Print GraphQL operations to stderr"),
|
|
51
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-essential output"),
|
|
52
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors"),
|
|
53
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print mutations without executing"),
|
|
54
|
+
timeout: float = typer.Option(30.0, "--timeout", help="HTTP timeout in seconds"),
|
|
55
|
+
version: Optional[bool] = typer.Option(
|
|
56
|
+
None, "--version", callback=_version_cb, is_eager=True, help="Show version and exit"
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Global flags applied before any subcommand."""
|
|
60
|
+
# Resolve config + token
|
|
61
|
+
config = load_config()
|
|
62
|
+
|
|
63
|
+
# Token resolution: only required for commands that hit the API.
|
|
64
|
+
# We defer the actual AuthError to first API call to allow `--help` etc.
|
|
65
|
+
try:
|
|
66
|
+
resolved_token = resolve_token(token, config)
|
|
67
|
+
except ClinearError:
|
|
68
|
+
resolved_token = ""
|
|
69
|
+
|
|
70
|
+
state = CLIState(
|
|
71
|
+
token=resolved_token,
|
|
72
|
+
config=config,
|
|
73
|
+
output=output,
|
|
74
|
+
verbose=verbose,
|
|
75
|
+
quiet=quiet,
|
|
76
|
+
no_color=no_color,
|
|
77
|
+
dry_run=dry_run,
|
|
78
|
+
timeout=timeout,
|
|
79
|
+
)
|
|
80
|
+
set_state(state)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Register subcommands
|
|
84
|
+
app.add_typer(me_app, name="me")
|
|
85
|
+
app.add_typer(auth_app, name="auth")
|
|
86
|
+
app.add_typer(init_app, name="init")
|
|
87
|
+
app.add_typer(team_app, name="team")
|
|
88
|
+
app.add_typer(issue_app, name="issue")
|
|
89
|
+
app.add_typer(project_app, name="project")
|
|
90
|
+
app.add_typer(cycle_app, name="cycle")
|
|
91
|
+
app.add_typer(comment_app, name="comment")
|
|
92
|
+
app.add_typer(label_app, name="label")
|
|
93
|
+
app.add_typer(raw_app, name="raw")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _entry() -> None:
|
|
97
|
+
"""Wrap typer_app() with consistent error handling and exit codes."""
|
|
98
|
+
try:
|
|
99
|
+
typer_app()
|
|
100
|
+
except ClinearError as e:
|
|
101
|
+
from clinear.cli_state import get_state
|
|
102
|
+
state = get_state()
|
|
103
|
+
if state.output == OutputFormat.JSON:
|
|
104
|
+
import json as _json
|
|
105
|
+
print(_json.dumps(e.to_dict(), indent=2), file=sys.stderr)
|
|
106
|
+
else:
|
|
107
|
+
emit_error(e.message, hint=e.hint)
|
|
108
|
+
sys.exit(int(e.exit_code))
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
emit_error("Interrupted")
|
|
111
|
+
sys.exit(130)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Public callable for `clinear` script entry point in pyproject.toml.
|
|
115
|
+
# Renamed to `typer_app` internally so the wrapper is what pip-installed
|
|
116
|
+
# `clinear` actually invokes.
|
|
117
|
+
typer_app = app
|
|
118
|
+
app = _entry # type: ignore[assignment]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
_entry()
|
clinear/cli_state.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
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 clinear.client import LinearClient
|
|
13
|
+
from clinear.config import Config
|
|
14
|
+
from clinear.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
|
+
|
|
30
|
+
|
|
31
|
+
_state = CLIState()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_state() -> CLIState:
|
|
35
|
+
return _state
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def set_state(state: CLIState) -> None:
|
|
39
|
+
global _state
|
|
40
|
+
_state = state
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_client(state: CLIState | None = None) -> LinearClient:
|
|
44
|
+
"""Construct a LinearClient configured from the current CLI state."""
|
|
45
|
+
s = state or _state
|
|
46
|
+
return LinearClient(
|
|
47
|
+
token=s.token,
|
|
48
|
+
timeout=s.timeout,
|
|
49
|
+
verbose=s.verbose,
|
|
50
|
+
)
|
clinear/client.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Async GraphQL client for the Linear 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
|
+
- Linear 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 asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, AsyncIterator, TypeVar
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from clinear import __version__
|
|
22
|
+
from clinear.config import redact_token
|
|
23
|
+
from clinear.errors import (
|
|
24
|
+
APIError,
|
|
25
|
+
AuthError,
|
|
26
|
+
NetworkError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
ValidationError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("clinear.client")
|
|
32
|
+
|
|
33
|
+
LINEAR_API_URL = "https://api.linear.app/graphql"
|
|
34
|
+
|
|
35
|
+
T = TypeVar("T", bound=BaseModel)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LinearClient:
|
|
39
|
+
"""Async GraphQL client for the Linear API."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
token: str,
|
|
44
|
+
*,
|
|
45
|
+
base_url: str = LINEAR_API_URL,
|
|
46
|
+
timeout: float = 30.0,
|
|
47
|
+
verbose: bool = False,
|
|
48
|
+
) -> None:
|
|
49
|
+
self._token = token
|
|
50
|
+
self._base_url = base_url
|
|
51
|
+
self._verbose = verbose
|
|
52
|
+
self._http = httpx.AsyncClient(
|
|
53
|
+
http2=False, # httpx[http2] is a separate dep — keep deps minimal
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
headers={
|
|
56
|
+
"Authorization": token,
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"User-Agent": f"clinear/{__version__} (+https://github.com/Clover/clinear)",
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def __aenter__(self) -> "LinearClient":
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
66
|
+
await self.close()
|
|
67
|
+
|
|
68
|
+
async def close(self) -> None:
|
|
69
|
+
await self._http.aclose()
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
# Core request method
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
async def execute(
|
|
76
|
+
self,
|
|
77
|
+
query: str,
|
|
78
|
+
variables: dict[str, Any] | None = None,
|
|
79
|
+
*,
|
|
80
|
+
operation: str | None = None,
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
"""Execute a GraphQL query/mutation. Returns the `data` portion.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
AuthError: 401
|
|
86
|
+
RateLimitError: 429
|
|
87
|
+
APIError: GraphQL errors in response
|
|
88
|
+
NetworkError: transport-level failures
|
|
89
|
+
"""
|
|
90
|
+
payload: dict[str, Any] = {"query": query}
|
|
91
|
+
if variables:
|
|
92
|
+
# Drop None values — Linear's API rejects explicit nulls in many inputs
|
|
93
|
+
payload["variables"] = _strip_none(variables)
|
|
94
|
+
if operation:
|
|
95
|
+
payload["operationName"] = operation
|
|
96
|
+
|
|
97
|
+
if self._verbose:
|
|
98
|
+
logger.info(
|
|
99
|
+
"POST %s [token=%s] op=%s vars=%s",
|
|
100
|
+
self._base_url,
|
|
101
|
+
redact_token(self._token),
|
|
102
|
+
operation or "<anon>",
|
|
103
|
+
json.dumps(payload.get("variables", {}), default=str)[:200],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
resp = await self._http.post(self._base_url, json=payload)
|
|
108
|
+
except httpx.TimeoutException as e:
|
|
109
|
+
raise NetworkError(f"Request timed out: {e}") from e
|
|
110
|
+
except httpx.RequestError as e:
|
|
111
|
+
raise NetworkError(f"Network error: {e}") from e
|
|
112
|
+
|
|
113
|
+
# Handle HTTP-level errors
|
|
114
|
+
if resp.status_code == 401:
|
|
115
|
+
raise AuthError(
|
|
116
|
+
"Linear rejected the token (HTTP 401)",
|
|
117
|
+
hint="Generate a new token at https://linear.app/settings/api",
|
|
118
|
+
)
|
|
119
|
+
if resp.status_code == 429:
|
|
120
|
+
retry = resp.headers.get("Retry-After")
|
|
121
|
+
raise RateLimitError(
|
|
122
|
+
"Linear API rate limit exceeded",
|
|
123
|
+
retry_after=int(retry) if retry and retry.isdigit() else None,
|
|
124
|
+
)
|
|
125
|
+
if resp.status_code >= 500:
|
|
126
|
+
raise APIError(
|
|
127
|
+
f"Linear API returned HTTP {resp.status_code}",
|
|
128
|
+
hint="This is usually transient — retry in a few seconds",
|
|
129
|
+
)
|
|
130
|
+
if resp.status_code >= 400:
|
|
131
|
+
raise APIError(
|
|
132
|
+
f"HTTP {resp.status_code}: {resp.text[:500]}",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
body = resp.json()
|
|
137
|
+
except json.JSONDecodeError as e:
|
|
138
|
+
raise APIError(f"Non-JSON response from Linear: {e}") from e
|
|
139
|
+
|
|
140
|
+
# GraphQL-level errors
|
|
141
|
+
if errors := body.get("errors"):
|
|
142
|
+
messages = "; ".join(e.get("message", "<unknown>") for e in errors)
|
|
143
|
+
raise APIError(messages, errors=errors)
|
|
144
|
+
|
|
145
|
+
data = body.get("data")
|
|
146
|
+
if data is None:
|
|
147
|
+
raise APIError("Linear returned empty data")
|
|
148
|
+
return data
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# Typed helpers
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
async def execute_as(
|
|
155
|
+
self,
|
|
156
|
+
model: type[T],
|
|
157
|
+
query: str,
|
|
158
|
+
variables: dict[str, Any] | None = None,
|
|
159
|
+
*,
|
|
160
|
+
path: list[str] | None = None,
|
|
161
|
+
operation: str | None = None,
|
|
162
|
+
) -> T:
|
|
163
|
+
"""Execute and validate the response against a Pydantic model.
|
|
164
|
+
|
|
165
|
+
path: list of dict keys to drill down to before parsing.
|
|
166
|
+
e.g. ["viewer"] turns {"viewer": {...}} into the viewer object.
|
|
167
|
+
"""
|
|
168
|
+
data = await self.execute(query, variables, operation=operation)
|
|
169
|
+
node: Any = data
|
|
170
|
+
for key in path or []:
|
|
171
|
+
if not isinstance(node, dict) or key not in node:
|
|
172
|
+
raise ValidationError(
|
|
173
|
+
f"Expected key {key!r} in response path "
|
|
174
|
+
f"{'.'.join(path or [])}",
|
|
175
|
+
)
|
|
176
|
+
node = node[key]
|
|
177
|
+
try:
|
|
178
|
+
return model.model_validate(node)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
raise ValidationError(
|
|
181
|
+
f"Response did not match {model.__name__}: {e}",
|
|
182
|
+
) from e
|
|
183
|
+
|
|
184
|
+
async def execute_list(
|
|
185
|
+
self,
|
|
186
|
+
model: type[T],
|
|
187
|
+
query: str,
|
|
188
|
+
variables: dict[str, Any] | None = None,
|
|
189
|
+
*,
|
|
190
|
+
path: list[str],
|
|
191
|
+
operation: str | None = None,
|
|
192
|
+
) -> list[T]:
|
|
193
|
+
"""Execute and validate a connection.nodes list response.
|
|
194
|
+
|
|
195
|
+
path should end at the dict containing 'nodes'.
|
|
196
|
+
"""
|
|
197
|
+
data = await self.execute(query, variables, operation=operation)
|
|
198
|
+
node: Any = data
|
|
199
|
+
for key in path:
|
|
200
|
+
if not isinstance(node, dict) or key not in node:
|
|
201
|
+
raise ValidationError(
|
|
202
|
+
f"Expected key {key!r} at path {'.'.join(path)}",
|
|
203
|
+
)
|
|
204
|
+
node = node[key]
|
|
205
|
+
if not isinstance(node, dict) or "nodes" not in node:
|
|
206
|
+
raise ValidationError(
|
|
207
|
+
"Expected a connection object with 'nodes' at path "
|
|
208
|
+
f"{'.'.join(path)}"
|
|
209
|
+
)
|
|
210
|
+
try:
|
|
211
|
+
return [model.model_validate(item) for item in node["nodes"]]
|
|
212
|
+
except Exception as e:
|
|
213
|
+
raise ValidationError(
|
|
214
|
+
f"List items did not match {model.__name__}: {e}",
|
|
215
|
+
) from e
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Pagination
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
async def paginate(
|
|
222
|
+
self,
|
|
223
|
+
query: str,
|
|
224
|
+
variables: dict[str, Any],
|
|
225
|
+
*,
|
|
226
|
+
path: list[str],
|
|
227
|
+
page_size: int = 50,
|
|
228
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
229
|
+
"""Yield every node across all pages of a connection."""
|
|
230
|
+
cursor: str | None = None
|
|
231
|
+
while True:
|
|
232
|
+
vars_with_cursor = {**variables, "first": page_size}
|
|
233
|
+
if cursor:
|
|
234
|
+
vars_with_cursor["after"] = cursor
|
|
235
|
+
data = await self.execute(query, vars_with_cursor)
|
|
236
|
+
node: Any = data
|
|
237
|
+
for key in path:
|
|
238
|
+
node = node[key]
|
|
239
|
+
for item in node.get("nodes", []):
|
|
240
|
+
yield item
|
|
241
|
+
page_info = node.get("pageInfo") or {}
|
|
242
|
+
if not page_info.get("hasNextPage"):
|
|
243
|
+
break
|
|
244
|
+
cursor = page_info.get("endCursor")
|
|
245
|
+
if not cursor:
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _strip_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
250
|
+
"""Recursively remove None values from a dict for GraphQL variables."""
|
|
251
|
+
if not isinstance(d, dict):
|
|
252
|
+
return d
|
|
253
|
+
out: dict[str, Any] = {}
|
|
254
|
+
for k, v in d.items():
|
|
255
|
+
if v is None:
|
|
256
|
+
continue
|
|
257
|
+
if isinstance(v, dict):
|
|
258
|
+
stripped = _strip_none(v)
|
|
259
|
+
if stripped:
|
|
260
|
+
out[k] = stripped
|
|
261
|
+
elif isinstance(v, list):
|
|
262
|
+
out[k] = [_strip_none(item) if isinstance(item, dict) else item for item in v]
|
|
263
|
+
else:
|
|
264
|
+
out[k] = v
|
|
265
|
+
return out
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""`clinear me` and `clinear auth ...` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from clinear.auth import get_viewer
|
|
10
|
+
from clinear.cli_state import build_client, get_state
|
|
11
|
+
from clinear.models.enums import OutputFormat
|
|
12
|
+
from clinear.output import render
|
|
13
|
+
|
|
14
|
+
me_app = typer.Typer(help="Show the currently-authenticated Linear user")
|
|
15
|
+
auth_app = typer.Typer(help="Authentication operations")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@me_app.callback(invoke_without_command=True)
|
|
19
|
+
def me(ctx: typer.Context) -> None:
|
|
20
|
+
"""Show the currently-authenticated user (alias for `auth status`)."""
|
|
21
|
+
if ctx.invoked_subcommand is not None:
|
|
22
|
+
return
|
|
23
|
+
asyncio.run(_run_me())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@auth_app.command("status")
|
|
27
|
+
def status() -> None:
|
|
28
|
+
"""Show the currently-authenticated user and token source."""
|
|
29
|
+
asyncio.run(_run_me())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@auth_app.command("whoami")
|
|
33
|
+
def whoami() -> None:
|
|
34
|
+
"""Alias of `auth status`."""
|
|
35
|
+
asyncio.run(_run_me())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _run_me() -> None:
|
|
39
|
+
state = get_state()
|
|
40
|
+
async with build_client(state) as client:
|
|
41
|
+
viewer = await get_viewer(client)
|
|
42
|
+
render(viewer, fmt=state.output, title="Viewer")
|