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.
@@ -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})