linear-python-client 0.1.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.
- linear_python_client/__init__.py +147 -0
- linear_python_client/client.py +627 -0
- linear_python_client/errors.py +78 -0
- linear_python_client/graphql/__init__.py +10 -0
- linear_python_client/graphql/queries.py +405 -0
- linear_python_client/models/__init__.py +132 -0
- linear_python_client/models/entities.py +187 -0
- linear_python_client/models/requests.py +260 -0
- linear_python_client/models/responses.py +169 -0
- linear_python_client/py.typed +0 -0
- linear_python_client-0.1.0.dist-info/METADATA +338 -0
- linear_python_client-0.1.0.dist-info/RECORD +14 -0
- linear_python_client-0.1.0.dist-info/WHEEL +4 -0
- linear_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""A pragmatic synchronous client for the Linear GraphQL API.
|
|
2
|
+
|
|
3
|
+
Every method takes a single typed request model from `linear_python_client.models.requests`
|
|
4
|
+
and returns a dedicated response model from `linear_python_client.models.responses`, so the
|
|
5
|
+
input and output of each call are explicit at the type level.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from .errors import (
|
|
17
|
+
LinearAuthenticationError,
|
|
18
|
+
LinearGraphQLError,
|
|
19
|
+
LinearNetworkError,
|
|
20
|
+
LinearRateLimitError,
|
|
21
|
+
)
|
|
22
|
+
from .graphql import queries
|
|
23
|
+
from .models.requests import (
|
|
24
|
+
CommentCreateRequest,
|
|
25
|
+
CommentRequest,
|
|
26
|
+
CommentsRequest,
|
|
27
|
+
FindWorkflowStateRequest,
|
|
28
|
+
IssueAddLabelRequest,
|
|
29
|
+
IssueArchiveRequest,
|
|
30
|
+
IssueCreateRequest,
|
|
31
|
+
IssueLabelsRequest,
|
|
32
|
+
IssueRemoveLabelRequest,
|
|
33
|
+
IssueRequest,
|
|
34
|
+
IssueSetStateRequest,
|
|
35
|
+
IssuesRequest,
|
|
36
|
+
IssueUpdateRequest,
|
|
37
|
+
PaginatedRequest,
|
|
38
|
+
ProjectRequest,
|
|
39
|
+
ProjectsRequest,
|
|
40
|
+
TeamRequest,
|
|
41
|
+
TeamsRequest,
|
|
42
|
+
UserRequest,
|
|
43
|
+
UsersRequest,
|
|
44
|
+
WorkflowStatesRequest,
|
|
45
|
+
)
|
|
46
|
+
from .models.responses import (
|
|
47
|
+
AddLabelResponse,
|
|
48
|
+
ArchiveIssueResponse,
|
|
49
|
+
CommentResponse,
|
|
50
|
+
CommentsResponse,
|
|
51
|
+
ConnectionResponse,
|
|
52
|
+
CreateCommentResponse,
|
|
53
|
+
CreateIssueResponse,
|
|
54
|
+
IssueDetailsResponse,
|
|
55
|
+
IssueLabelsResponse,
|
|
56
|
+
IssueResponse,
|
|
57
|
+
IssuesResponse,
|
|
58
|
+
ProjectResponse,
|
|
59
|
+
ProjectsResponse,
|
|
60
|
+
RemoveLabelResponse,
|
|
61
|
+
TeamResponse,
|
|
62
|
+
TeamsResponse,
|
|
63
|
+
UpdateIssueResponse,
|
|
64
|
+
UserResponse,
|
|
65
|
+
UsersResponse,
|
|
66
|
+
ViewerResponse,
|
|
67
|
+
WorkflowStateResponse,
|
|
68
|
+
WorkflowStatesResponse,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
DEFAULT_ENDPOINT = "https://api.linear.app/graphql"
|
|
72
|
+
|
|
73
|
+
RequestT = TypeVar("RequestT", bound=PaginatedRequest)
|
|
74
|
+
NodeT = TypeVar("NodeT")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _to_int(value: str | None) -> int | None:
|
|
78
|
+
if value is None:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
return int(value)
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LinearClient:
|
|
87
|
+
"""Client for Linear's GraphQL API.
|
|
88
|
+
|
|
89
|
+
Authenticate with either a personal API key or an OAuth access token:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
client = LinearClient(api_key="lin_api_...")
|
|
93
|
+
client = LinearClient(access_token="...") # OAuth 2.0 token
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
If neither argument is given, the `LINEAR_API_KEY` environment variable is
|
|
97
|
+
used. The client owns an `httpx.Client` and can be used as a context manager
|
|
98
|
+
to ensure it is closed.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
api_key: str | None = None,
|
|
104
|
+
access_token: str | None = None,
|
|
105
|
+
*,
|
|
106
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
107
|
+
timeout: float = 30.0,
|
|
108
|
+
http_client: httpx.Client | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Create a client.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
api_key: A Linear personal API key, sent verbatim in the
|
|
114
|
+
`Authorization` header. Mutually exclusive with `access_token`.
|
|
115
|
+
access_token: An OAuth 2.0 access token, sent as
|
|
116
|
+
`Authorization: Bearer <token>`. Mutually exclusive with `api_key`.
|
|
117
|
+
endpoint: GraphQL endpoint URL. Defaults to the public Linear API.
|
|
118
|
+
timeout: Per-request timeout in seconds for the owned HTTP client.
|
|
119
|
+
http_client: An existing `httpx.Client` to reuse. When supplied, the
|
|
120
|
+
caller retains ownership and `close()` will not close it.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: If both or neither credential is provided (and
|
|
124
|
+
`LINEAR_API_KEY` is unset).
|
|
125
|
+
"""
|
|
126
|
+
if api_key and access_token:
|
|
127
|
+
raise ValueError("Provide either api_key or access_token, not both.")
|
|
128
|
+
if not api_key and not access_token:
|
|
129
|
+
api_key = os.environ.get("LINEAR_API_KEY")
|
|
130
|
+
if not api_key and not access_token:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"No credentials supplied. Pass api_key=... or access_token=..., "
|
|
133
|
+
"or set the LINEAR_API_KEY environment variable."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Personal API keys are sent verbatim; OAuth tokens use the Bearer scheme.
|
|
137
|
+
authorization = api_key if api_key else f"Bearer {access_token}"
|
|
138
|
+
self.endpoint = endpoint
|
|
139
|
+
self._owns_client = http_client is None
|
|
140
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
141
|
+
self._headers = {
|
|
142
|
+
"Authorization": authorization,
|
|
143
|
+
"Content-Type": "application/json",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# -- lifecycle ----------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
"""Close the underlying HTTP client, unless it was supplied by the caller."""
|
|
150
|
+
if self._owns_client:
|
|
151
|
+
self._http.close()
|
|
152
|
+
|
|
153
|
+
def __enter__(self) -> LinearClient:
|
|
154
|
+
"""Enter a context manager, returning this client."""
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def __exit__(self, *exc: object) -> None:
|
|
158
|
+
"""Exit the context manager and close the HTTP client."""
|
|
159
|
+
self.close()
|
|
160
|
+
|
|
161
|
+
# -- core request -------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def execute(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
164
|
+
"""Run a raw GraphQL query or mutation and return its `data` payload.
|
|
165
|
+
|
|
166
|
+
This is the escape hatch backing every convenience method; use it
|
|
167
|
+
directly for any operation the typed methods do not cover.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
query: The GraphQL document to send.
|
|
171
|
+
variables: Optional variables for the query.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The `data` object from the response (an empty dict if absent).
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
LinearAuthenticationError: The credentials were rejected.
|
|
178
|
+
LinearRateLimitError: A rate limit was exceeded.
|
|
179
|
+
LinearGraphQLError: The API returned one or more GraphQL errors.
|
|
180
|
+
LinearNetworkError: The request never produced a usable response.
|
|
181
|
+
"""
|
|
182
|
+
payload: dict[str, Any] = {"query": query}
|
|
183
|
+
if variables is not None:
|
|
184
|
+
payload["variables"] = variables
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
response = self._http.post(self.endpoint, json=payload, headers=self._headers)
|
|
188
|
+
except httpx.HTTPError as exc:
|
|
189
|
+
raise LinearNetworkError(f"Request to Linear failed: {exc}") from exc
|
|
190
|
+
|
|
191
|
+
if response.status_code in (401, 403):
|
|
192
|
+
raise LinearAuthenticationError(
|
|
193
|
+
f"Linear rejected the credentials (HTTP {response.status_code})."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
body = response.json()
|
|
198
|
+
except ValueError as exc:
|
|
199
|
+
raise LinearNetworkError(
|
|
200
|
+
f"Linear returned a non-JSON response (HTTP {response.status_code})."
|
|
201
|
+
) from exc
|
|
202
|
+
|
|
203
|
+
errors = body.get("errors")
|
|
204
|
+
if errors:
|
|
205
|
+
self._raise_for_errors(errors, response)
|
|
206
|
+
|
|
207
|
+
if response.status_code >= 400:
|
|
208
|
+
raise LinearNetworkError(
|
|
209
|
+
f"Linear returned HTTP {response.status_code} with no GraphQL errors."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return body.get("data") or {}
|
|
213
|
+
|
|
214
|
+
def _raise_for_errors(self, errors: list[dict[str, Any]], response: httpx.Response) -> None:
|
|
215
|
+
"""Map a GraphQL ``errors`` list onto the appropriate exception and raise it."""
|
|
216
|
+
code = None
|
|
217
|
+
for error in errors:
|
|
218
|
+
extensions = error.get("extensions") or {}
|
|
219
|
+
code = extensions.get("code") or error.get("code")
|
|
220
|
+
if code:
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
message = errors[0].get("message", "GraphQL error") if errors else "GraphQL error"
|
|
224
|
+
|
|
225
|
+
if code == "RATELIMITED":
|
|
226
|
+
headers = response.headers
|
|
227
|
+
raise LinearRateLimitError(
|
|
228
|
+
message,
|
|
229
|
+
requests_limit=_to_int(headers.get("X-RateLimit-Requests-Limit")),
|
|
230
|
+
requests_remaining=_to_int(headers.get("X-RateLimit-Requests-Remaining")),
|
|
231
|
+
requests_reset=_to_int(headers.get("X-RateLimit-Requests-Reset")),
|
|
232
|
+
complexity_limit=_to_int(headers.get("X-RateLimit-Complexity-Limit")),
|
|
233
|
+
complexity_remaining=_to_int(headers.get("X-RateLimit-Complexity-Remaining")),
|
|
234
|
+
complexity_reset=_to_int(headers.get("X-RateLimit-Complexity-Reset")),
|
|
235
|
+
)
|
|
236
|
+
if code in ("AUTHENTICATION_ERROR", "FORBIDDEN"):
|
|
237
|
+
raise LinearAuthenticationError(message)
|
|
238
|
+
|
|
239
|
+
raise LinearGraphQLError(message, errors=errors)
|
|
240
|
+
|
|
241
|
+
# -- viewer / users -----------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def viewer(self) -> ViewerResponse:
|
|
244
|
+
"""Fetch the currently authenticated user.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
A [`ViewerResponse`][linear_python_client.ViewerResponse].
|
|
248
|
+
"""
|
|
249
|
+
return ViewerResponse.model_validate(self.execute(queries.VIEWER))
|
|
250
|
+
|
|
251
|
+
def user(self, request: UserRequest) -> UserResponse:
|
|
252
|
+
"""Fetch a single user by id.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
request: A [`UserRequest`][linear_python_client.UserRequest].
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
A [`UserResponse`][linear_python_client.UserResponse]; `.user` is
|
|
259
|
+
`None` if not found.
|
|
260
|
+
"""
|
|
261
|
+
return UserResponse.model_validate(self.execute(queries.USER, {"id": request.id}))
|
|
262
|
+
|
|
263
|
+
def users(self, request: UsersRequest | None = None) -> UsersResponse:
|
|
264
|
+
"""List users in the workspace.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
request: A [`UsersRequest`][linear_python_client.UsersRequest]. When
|
|
268
|
+
omitted, the first page is returned with no filter.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
A [`UsersResponse`][linear_python_client.UsersResponse].
|
|
272
|
+
"""
|
|
273
|
+
request = request or UsersRequest()
|
|
274
|
+
data = self.execute(queries.USERS, request.to_variables())
|
|
275
|
+
return UsersResponse.model_validate(data.get("users") or {})
|
|
276
|
+
|
|
277
|
+
# -- teams --------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def team(self, request: TeamRequest) -> TeamResponse:
|
|
280
|
+
"""Fetch a single team by id.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
request: A [`TeamRequest`][linear_python_client.TeamRequest].
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
A [`TeamResponse`][linear_python_client.TeamResponse]; `.team` is
|
|
287
|
+
`None` if not found.
|
|
288
|
+
"""
|
|
289
|
+
return TeamResponse.model_validate(self.execute(queries.TEAM, {"id": request.id}))
|
|
290
|
+
|
|
291
|
+
def teams(self, request: TeamsRequest | None = None) -> TeamsResponse:
|
|
292
|
+
"""List teams in the workspace.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
request: A [`TeamsRequest`][linear_python_client.TeamsRequest]. When
|
|
296
|
+
omitted, the first page is returned with no filter.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
A [`TeamsResponse`][linear_python_client.TeamsResponse].
|
|
300
|
+
"""
|
|
301
|
+
request = request or TeamsRequest()
|
|
302
|
+
data = self.execute(queries.TEAMS, request.to_variables())
|
|
303
|
+
return TeamsResponse.model_validate(data.get("teams") or {})
|
|
304
|
+
|
|
305
|
+
# -- issues -------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def issue(self, request: IssueRequest) -> IssueResponse:
|
|
308
|
+
"""Fetch a single issue by id or human identifier.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
request: An [`IssueRequest`][linear_python_client.IssueRequest].
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
An [`IssueResponse`][linear_python_client.IssueResponse]; `.issue`
|
|
315
|
+
is `None` if not found.
|
|
316
|
+
"""
|
|
317
|
+
return IssueResponse.model_validate(self.execute(queries.ISSUE, {"id": request.id}))
|
|
318
|
+
|
|
319
|
+
def issue_details(self, request: IssueRequest) -> IssueDetailsResponse:
|
|
320
|
+
"""Fetch a single issue with its full related data.
|
|
321
|
+
|
|
322
|
+
Returns the same core fields as [`issue`][linear_python_client.client.LinearClient.issue]
|
|
323
|
+
plus comments, attachments, project, cycle, parent, sub-issues,
|
|
324
|
+
subscribers, and relations.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
request: An [`IssueRequest`][linear_python_client.IssueRequest].
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
An [`IssueDetailsResponse`][linear_python_client.IssueDetailsResponse];
|
|
331
|
+
`.issue` is an [`IssueDetail`][linear_python_client.IssueDetail], or
|
|
332
|
+
`None` if not found.
|
|
333
|
+
"""
|
|
334
|
+
data = self.execute(queries.ISSUE_DETAILS, {"id": request.id})
|
|
335
|
+
return IssueDetailsResponse.model_validate(data)
|
|
336
|
+
|
|
337
|
+
def issues(self, request: IssuesRequest | None = None) -> IssuesResponse:
|
|
338
|
+
"""List issues, optionally filtered and ordered.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
request: An [`IssuesRequest`][linear_python_client.IssuesRequest].
|
|
342
|
+
When omitted, the first page is returned with no filter.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
An [`IssuesResponse`][linear_python_client.IssuesResponse].
|
|
346
|
+
"""
|
|
347
|
+
request = request or IssuesRequest()
|
|
348
|
+
data = self.execute(queries.ISSUES, request.to_variables())
|
|
349
|
+
return IssuesResponse.model_validate(data.get("issues") or {})
|
|
350
|
+
|
|
351
|
+
def create_issue(self, request: IssueCreateRequest) -> CreateIssueResponse:
|
|
352
|
+
"""Create an issue.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
request: An [`IssueCreateRequest`][linear_python_client.IssueCreateRequest].
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
A [`CreateIssueResponse`][linear_python_client.CreateIssueResponse]
|
|
359
|
+
exposing `success` and the created `issue`.
|
|
360
|
+
"""
|
|
361
|
+
data = self.execute(queries.ISSUE_CREATE, {"input": request.to_input()})
|
|
362
|
+
return CreateIssueResponse.model_validate(data.get("issueCreate") or {})
|
|
363
|
+
|
|
364
|
+
def update_issue(self, request: IssueUpdateRequest) -> UpdateIssueResponse:
|
|
365
|
+
"""Update an issue.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
request: An [`IssueUpdateRequest`][linear_python_client.IssueUpdateRequest]
|
|
369
|
+
with `id` and at least one field to change.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
An [`UpdateIssueResponse`][linear_python_client.UpdateIssueResponse]
|
|
373
|
+
exposing `success` and the updated `issue`.
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
ValueError: If no fields besides `id` are set.
|
|
377
|
+
"""
|
|
378
|
+
input_data = request.to_input()
|
|
379
|
+
if not input_data:
|
|
380
|
+
raise ValueError("IssueUpdateRequest requires at least one field to update.")
|
|
381
|
+
data = self.execute(queries.ISSUE_UPDATE, {"id": request.id, "input": input_data})
|
|
382
|
+
return UpdateIssueResponse.model_validate(data.get("issueUpdate") or {})
|
|
383
|
+
|
|
384
|
+
def archive_issue(self, request: IssueArchiveRequest) -> ArchiveIssueResponse:
|
|
385
|
+
"""Archive an issue.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
request: An [`IssueArchiveRequest`][linear_python_client.IssueArchiveRequest].
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
An [`ArchiveIssueResponse`][linear_python_client.ArchiveIssueResponse]
|
|
392
|
+
exposing `success`.
|
|
393
|
+
"""
|
|
394
|
+
data = self.execute(queries.ISSUE_ARCHIVE, {"id": request.id})
|
|
395
|
+
return ArchiveIssueResponse.model_validate(data.get("issueArchive") or {})
|
|
396
|
+
|
|
397
|
+
def add_label(self, request: IssueAddLabelRequest) -> AddLabelResponse:
|
|
398
|
+
"""Add a single label to an issue without disturbing its other labels.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
request: An [`IssueAddLabelRequest`][linear_python_client.IssueAddLabelRequest].
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
An [`AddLabelResponse`][linear_python_client.AddLabelResponse] exposing
|
|
405
|
+
`success` and the updated `issue`.
|
|
406
|
+
"""
|
|
407
|
+
data = self.execute(
|
|
408
|
+
queries.ISSUE_ADD_LABEL, {"id": request.id, "labelId": request.label_id}
|
|
409
|
+
)
|
|
410
|
+
return AddLabelResponse.model_validate(data.get("issueAddLabel") or {})
|
|
411
|
+
|
|
412
|
+
def remove_label(self, request: IssueRemoveLabelRequest) -> RemoveLabelResponse:
|
|
413
|
+
"""Remove a single label from an issue without disturbing its other labels.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
request: An [`IssueRemoveLabelRequest`][linear_python_client.IssueRemoveLabelRequest].
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
A [`RemoveLabelResponse`][linear_python_client.RemoveLabelResponse]
|
|
420
|
+
exposing `success` and the updated `issue`.
|
|
421
|
+
"""
|
|
422
|
+
data = self.execute(
|
|
423
|
+
queries.ISSUE_REMOVE_LABEL, {"id": request.id, "labelId": request.label_id}
|
|
424
|
+
)
|
|
425
|
+
return RemoveLabelResponse.model_validate(data.get("issueRemoveLabel") or {})
|
|
426
|
+
|
|
427
|
+
def set_issue_state(self, request: IssueSetStateRequest) -> UpdateIssueResponse:
|
|
428
|
+
"""Move an issue to a workflow state (status).
|
|
429
|
+
|
|
430
|
+
A focused wrapper over `update_issue` that sets only the state. Resolve a
|
|
431
|
+
state UUID by name with
|
|
432
|
+
[`find_workflow_state`][linear_python_client.client.LinearClient.find_workflow_state].
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
request: An [`IssueSetStateRequest`][linear_python_client.IssueSetStateRequest].
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
An [`UpdateIssueResponse`][linear_python_client.UpdateIssueResponse]
|
|
439
|
+
exposing `success` and the updated `issue`.
|
|
440
|
+
"""
|
|
441
|
+
data = self.execute(
|
|
442
|
+
queries.ISSUE_UPDATE, {"id": request.id, "input": {"stateId": request.state_id}}
|
|
443
|
+
)
|
|
444
|
+
return UpdateIssueResponse.model_validate(data.get("issueUpdate") or {})
|
|
445
|
+
|
|
446
|
+
# -- projects -----------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def project(self, request: ProjectRequest) -> ProjectResponse:
|
|
449
|
+
"""Fetch a single project by id.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
request: A [`ProjectRequest`][linear_python_client.ProjectRequest].
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
A [`ProjectResponse`][linear_python_client.ProjectResponse];
|
|
456
|
+
`.project` is `None` if not found.
|
|
457
|
+
"""
|
|
458
|
+
return ProjectResponse.model_validate(self.execute(queries.PROJECT, {"id": request.id}))
|
|
459
|
+
|
|
460
|
+
def projects(self, request: ProjectsRequest | None = None) -> ProjectsResponse:
|
|
461
|
+
"""List projects in the workspace.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
request: A [`ProjectsRequest`][linear_python_client.ProjectsRequest].
|
|
465
|
+
When omitted, the first page is returned with no filter.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
A [`ProjectsResponse`][linear_python_client.ProjectsResponse].
|
|
469
|
+
"""
|
|
470
|
+
request = request or ProjectsRequest()
|
|
471
|
+
data = self.execute(queries.PROJECTS, request.to_variables())
|
|
472
|
+
return ProjectsResponse.model_validate(data.get("projects") or {})
|
|
473
|
+
|
|
474
|
+
# -- comments -----------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
def comment(self, request: CommentRequest) -> CommentResponse:
|
|
477
|
+
"""Fetch a single comment by id.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
request: A [`CommentRequest`][linear_python_client.CommentRequest].
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
A [`CommentResponse`][linear_python_client.CommentResponse];
|
|
484
|
+
`.comment` is `None` if not found.
|
|
485
|
+
"""
|
|
486
|
+
return CommentResponse.model_validate(self.execute(queries.COMMENT, {"id": request.id}))
|
|
487
|
+
|
|
488
|
+
def comments(self, request: CommentsRequest | None = None) -> CommentsResponse:
|
|
489
|
+
"""List comments, optionally scoped to a single issue.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
request: A [`CommentsRequest`][linear_python_client.CommentsRequest].
|
|
493
|
+
Set `issue_id` to scope to one issue. When omitted, the first page
|
|
494
|
+
is returned with no filter.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
A [`CommentsResponse`][linear_python_client.CommentsResponse].
|
|
498
|
+
"""
|
|
499
|
+
request = request or CommentsRequest()
|
|
500
|
+
variables = request.to_variables()
|
|
501
|
+
if request.issue_id:
|
|
502
|
+
issue_filter = {"issue": {"id": {"eq": request.issue_id}}}
|
|
503
|
+
existing = variables.get("filter")
|
|
504
|
+
variables["filter"] = {**issue_filter, **existing} if existing else issue_filter
|
|
505
|
+
data = self.execute(queries.COMMENTS, variables)
|
|
506
|
+
return CommentsResponse.model_validate(data.get("comments") or {})
|
|
507
|
+
|
|
508
|
+
def create_comment(self, request: CommentCreateRequest) -> CreateCommentResponse:
|
|
509
|
+
"""Add a comment to an issue.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
request: A [`CommentCreateRequest`][linear_python_client.CommentCreateRequest].
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
A [`CreateCommentResponse`][linear_python_client.CreateCommentResponse]
|
|
516
|
+
exposing `success` and the created `comment`.
|
|
517
|
+
"""
|
|
518
|
+
data = self.execute(queries.COMMENT_CREATE, {"input": request.to_input()})
|
|
519
|
+
return CreateCommentResponse.model_validate(data.get("commentCreate") or {})
|
|
520
|
+
|
|
521
|
+
# -- workflow states / labels ------------------------------------------
|
|
522
|
+
|
|
523
|
+
def workflow_states(
|
|
524
|
+
self, request: WorkflowStatesRequest | None = None
|
|
525
|
+
) -> WorkflowStatesResponse:
|
|
526
|
+
"""List workflow states, optionally scoped to a single team.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
request: A [`WorkflowStatesRequest`][linear_python_client.WorkflowStatesRequest].
|
|
530
|
+
Set `team_id` to scope to one team. When omitted, the first page is
|
|
531
|
+
returned with no filter.
|
|
532
|
+
|
|
533
|
+
Returns:
|
|
534
|
+
A [`WorkflowStatesResponse`][linear_python_client.WorkflowStatesResponse].
|
|
535
|
+
"""
|
|
536
|
+
request = request or WorkflowStatesRequest()
|
|
537
|
+
variables = request.to_variables()
|
|
538
|
+
if request.team_id:
|
|
539
|
+
team_filter = {"team": {"id": {"eq": request.team_id}}}
|
|
540
|
+
existing = variables.get("filter")
|
|
541
|
+
variables["filter"] = {**team_filter, **existing} if existing else team_filter
|
|
542
|
+
data = self.execute(queries.WORKFLOW_STATES, variables)
|
|
543
|
+
return WorkflowStatesResponse.model_validate(data.get("workflowStates") or {})
|
|
544
|
+
|
|
545
|
+
def find_workflow_state(self, request: FindWorkflowStateRequest) -> WorkflowStateResponse:
|
|
546
|
+
"""Resolve a workflow state by name within a team.
|
|
547
|
+
|
|
548
|
+
Useful for turning a human status name (e.g. `"In Progress"`) into the
|
|
549
|
+
UUID that [`set_issue_state`][linear_python_client.client.LinearClient.set_issue_state]
|
|
550
|
+
expects. Matching is case-insensitive.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
request: A [`FindWorkflowStateRequest`][linear_python_client.FindWorkflowStateRequest].
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
A [`WorkflowStateResponse`][linear_python_client.WorkflowStateResponse];
|
|
557
|
+
`.state` is `None` if no state matches.
|
|
558
|
+
"""
|
|
559
|
+
variables = {
|
|
560
|
+
"first": 1,
|
|
561
|
+
"after": None,
|
|
562
|
+
"filter": {
|
|
563
|
+
"team": {"id": {"eq": request.team_id}},
|
|
564
|
+
"name": {"eqIgnoreCase": request.name},
|
|
565
|
+
},
|
|
566
|
+
}
|
|
567
|
+
data = self.execute(queries.WORKFLOW_STATES, variables)
|
|
568
|
+
nodes = (data.get("workflowStates") or {}).get("nodes") or []
|
|
569
|
+
return WorkflowStateResponse.model_validate({"state": nodes[0] if nodes else None})
|
|
570
|
+
|
|
571
|
+
def issue_labels(self, request: IssueLabelsRequest | None = None) -> IssueLabelsResponse:
|
|
572
|
+
"""List issue labels in the workspace.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
request: An [`IssueLabelsRequest`][linear_python_client.IssueLabelsRequest].
|
|
576
|
+
When omitted, the first page is returned with no filter.
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
An [`IssueLabelsResponse`][linear_python_client.IssueLabelsResponse].
|
|
580
|
+
"""
|
|
581
|
+
request = request or IssueLabelsRequest()
|
|
582
|
+
data = self.execute(queries.ISSUE_LABELS, request.to_variables())
|
|
583
|
+
return IssueLabelsResponse.model_validate(data.get("issueLabels") or {})
|
|
584
|
+
|
|
585
|
+
# -- pagination ---------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
def paginate(
|
|
588
|
+
self,
|
|
589
|
+
method: Callable[[RequestT], ConnectionResponse[NodeT]],
|
|
590
|
+
request: RequestT,
|
|
591
|
+
*,
|
|
592
|
+
page_size: int | None = None,
|
|
593
|
+
) -> Iterator[NodeT]:
|
|
594
|
+
"""Yield every node across all pages of a list method.
|
|
595
|
+
|
|
596
|
+
Transparently follows the cursor (`page_info.end_cursor`) until
|
|
597
|
+
`has_next_page` is false, so you can iterate an entire result set without
|
|
598
|
+
managing pagination yourself:
|
|
599
|
+
|
|
600
|
+
```python
|
|
601
|
+
from linear_python_client import IssuesRequest
|
|
602
|
+
|
|
603
|
+
for issue in client.paginate(client.issues, IssuesRequest(filter={...})):
|
|
604
|
+
print(issue.identifier, issue.title)
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
method: A list method on this client (e.g. `client.issues`,
|
|
609
|
+
`client.teams`, `client.projects`).
|
|
610
|
+
request: The request to start from. It is copied; `after` is advanced
|
|
611
|
+
automatically each page.
|
|
612
|
+
page_size: Results to request per page, set as `first`. Leave unset to
|
|
613
|
+
use Linear's default of 50.
|
|
614
|
+
|
|
615
|
+
Yields:
|
|
616
|
+
Each node from every page, in order.
|
|
617
|
+
"""
|
|
618
|
+
current = request.model_copy(deep=True)
|
|
619
|
+
if page_size is not None:
|
|
620
|
+
current.first = page_size
|
|
621
|
+
while True:
|
|
622
|
+
response = method(current)
|
|
623
|
+
yield from response.nodes
|
|
624
|
+
page = response.page_info
|
|
625
|
+
if not page.has_next_page or not page.end_cursor:
|
|
626
|
+
break
|
|
627
|
+
current = current.model_copy(update={"after": page.end_cursor})
|