Skip to content

Commit f58e97d

Browse files
Add find_team, find_user, find_project, and find_label resolvers
1 parent 3b260b4 commit f58e97d

13 files changed

Lines changed: 443 additions & 15 deletions

File tree

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,33 @@ for child in detail.children:
162162
print("sub-issue:", child.identifier, child.title)
163163
```
164164

165+
## Looking things up by name (instead of UUIDs)
166+
167+
Most calls take UUIDs. Use the `find_*` resolvers to turn a human name/key/email into
168+
the entity (and its `.id`) first:
169+
170+
```python
171+
from linear_python_client import (
172+
FindTeamRequest, FindUserRequest, FindProjectRequest, FindLabelRequest,
173+
IssueCreateRequest,
174+
)
175+
176+
team = client.find_team(FindTeamRequest(key="RAV")).team # or name="Ravens"
177+
assignee = client.find_user(FindUserRequest(name="Elijah Winter")).user # or email=...
178+
bug = client.find_label(FindLabelRequest(name="bug", team_id=team.id)).label
179+
180+
client.create_issue(IssueCreateRequest(
181+
team_id=team.id,
182+
title="New issue",
183+
assignee_id=assignee.id,
184+
label_ids=[bug.id],
185+
))
186+
```
187+
188+
Each resolver returns the matching entity, or `None` if nothing matches. Name matching
189+
is case-insensitive; team `key` is matched exactly. `find_workflow_state` (for statuses)
190+
works the same way.
191+
165192
## Escape hatch: raw GraphQL
166193

167194
Anything not covered by a convenience method can be run directly. `execute()`
@@ -225,8 +252,12 @@ Each method maps a `*Request` to a `*Response`:
225252
| `comments(...)` | `CommentsRequest` | `CommentsResponse` |
226253
| `create_comment(...)` | `CommentCreateRequest` | `CreateCommentResponse` |
227254
| `workflow_states(...)` | `WorkflowStatesRequest` | `WorkflowStatesResponse` |
228-
| `find_workflow_state(...)` | `FindWorkflowStateRequest` | `WorkflowStateResponse` |
229255
| `issue_labels(...)` | `IssueLabelsRequest` | `IssueLabelsResponse` |
256+
| `find_team(...)` | `FindTeamRequest` | `TeamResponse` |
257+
| `find_user(...)` | `FindUserRequest` | `UserResponse` |
258+
| `find_project(...)` | `FindProjectRequest` | `ProjectResponse` |
259+
| `find_label(...)` | `FindLabelRequest` | `IssueLabelResponse` |
260+
| `find_workflow_state(...)` | `FindWorkflowStateRequest` | `WorkflowStateResponse` |
230261
| `execute(query, variables)` || `dict` |
231262
| `paginate(method, request)` | a `*Request` | iterator of nodes |
232263

docs/api/requests.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,8 @@ method takes exactly one of these.
2828
- IssueRemoveLabelRequest
2929
- IssueSetStateRequest
3030
- FindWorkflowStateRequest
31+
- FindTeamRequest
32+
- FindUserRequest
33+
- FindProjectRequest
34+
- FindLabelRequest
3135
- CommentCreateRequest

docs/api/responses.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ The typed result models returned by each
2323
- CommentsResponse
2424
- WorkflowStateResponse
2525
- WorkflowStatesResponse
26+
- IssueLabelResponse
2627
- IssueLabelsResponse
2728
- CreateIssueResponse
2829
- UpdateIssueResponse

docs/usage.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,23 @@ states = client.workflow_states(WorkflowStatesRequest(team_id="..."))
214214
labels = client.issue_labels(IssueLabelsRequest(first=100))
215215
```
216216

217+
## Resolving names to UUIDs
218+
219+
Most methods take UUIDs. The `find_*` resolvers turn a human name/key/email into the
220+
entity (read its `.id` to pass elsewhere). Each returns the matching entity or `None`;
221+
name matching is case-insensitive, team `key` is exact.
222+
223+
```python
224+
from linear_python_client import (
225+
FindTeamRequest, FindUserRequest, FindProjectRequest, FindLabelRequest,
226+
)
227+
228+
team = client.find_team(FindTeamRequest(key="RAV")).team # or name="Ravens"
229+
user = client.find_user(FindUserRequest(name="Elijah Winter")).user # or email="..."
230+
project = client.find_project(FindProjectRequest(name="Roadmap")).project
231+
bug = client.find_label(FindLabelRequest(name="bug", team_id=team.id)).label
232+
```
233+
217234
## Raw GraphQL
218235

219236
Anything not covered by a typed method can be run directly with

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "linear-python-client"
3-
version = "0.1.1"
3+
version = "0.2.0"
44
description = "Pragmatic Python client for the Linear GraphQL API"
55
readme = "README.md"
66
license = "MIT"

scripts/smoke_test.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
CommentCreateRequest,
3030
CommentRequest,
3131
CommentsRequest,
32+
FindLabelRequest,
33+
FindProjectRequest,
34+
FindTeamRequest,
35+
FindUserRequest,
3236
FindWorkflowStateRequest,
3337
IssueAddLabelRequest,
3438
IssueArchiveRequest,
@@ -162,6 +166,30 @@ def main() -> int:
162166
except Exception as exc: # noqa: BLE001
163167
r.check("paginate(issues)", False, f"raised {type(exc).__name__}: {exc}")
164168

169+
# -- name/key resolvers ---------------------------------------------
170+
section("Resolvers (name/key -> entity)")
171+
resolved_team = r.run(
172+
"find_team(by key)",
173+
lambda: client.find_team(FindTeamRequest(key=team.key)).team,
174+
)
175+
r.check(
176+
"find_team resolves to same id",
177+
bool(resolved_team and resolved_team.id == team_id),
178+
)
179+
if viewer and viewer.name:
180+
found_user = r.run(
181+
"find_user(by name)",
182+
lambda: client.find_user(FindUserRequest(name=viewer.name)).user,
183+
)
184+
r.check("find_user resolves to a user", bool(found_user and found_user.id))
185+
if projects:
186+
r.run(
187+
"find_project(by name)",
188+
lambda: client.find_project(FindProjectRequest(name=projects[0].name)).project,
189+
)
190+
else:
191+
r.skip("find_project()", "no projects in workspace")
192+
165193
# -- create + verify ------------------------------------------------
166194
section("Create issue (+ pull to verify)")
167195
title = f"{MARKER} {int(time.time())}"
@@ -244,6 +272,13 @@ def pull():
244272
)
245273
if team_labels:
246274
label = team_labels[0]
275+
resolved_label = r.run(
276+
"find_label(by name)",
277+
lambda: client.find_label(
278+
FindLabelRequest(name=label.name, team_id=team_id)
279+
).label,
280+
)
281+
r.check("find_label resolves a label", bool(resolved_label and resolved_label.id))
247282
r.run(
248283
"add_label()",
249284
lambda: client.add_label(

src/linear_python_client/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
CommentCreateRequest,
3030
CommentRequest,
3131
CommentsRequest,
32+
FindLabelRequest,
33+
FindProjectRequest,
34+
FindTeamRequest,
35+
FindUserRequest,
3236
FindWorkflowStateRequest,
3337
IssueAddLabelRequest,
3438
IssueArchiveRequest,
@@ -57,6 +61,7 @@
5761
CreateCommentResponse,
5862
CreateIssueResponse,
5963
IssueDetailsResponse,
64+
IssueLabelResponse,
6065
IssueLabelsResponse,
6166
IssueResponse,
6267
IssuesResponse,
@@ -73,7 +78,7 @@
7378
WorkflowStatesResponse,
7479
)
7580

76-
__version__ = "0.1.1"
81+
__version__ = "0.2.0"
7782

7883
__all__ = [
7984
"DEFAULT_ENDPOINT",
@@ -113,6 +118,10 @@
113118
"IssueRemoveLabelRequest",
114119
"IssueSetStateRequest",
115120
"FindWorkflowStateRequest",
121+
"FindTeamRequest",
122+
"FindUserRequest",
123+
"FindLabelRequest",
124+
"FindProjectRequest",
116125
"ProjectRequest",
117126
"ProjectsRequest",
118127
"CommentRequest",
@@ -142,6 +151,7 @@
142151
"CreateCommentResponse",
143152
"WorkflowStateResponse",
144153
"WorkflowStatesResponse",
154+
"IssueLabelResponse",
145155
"IssueLabelsResponse",
146156
"__version__",
147157
]

src/linear_python_client/client.py

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
CommentCreateRequest,
2525
CommentRequest,
2626
CommentsRequest,
27+
FindLabelRequest,
28+
FindProjectRequest,
29+
FindTeamRequest,
30+
FindUserRequest,
2731
FindWorkflowStateRequest,
2832
IssueAddLabelRequest,
2933
IssueArchiveRequest,
@@ -52,6 +56,7 @@
5256
CreateCommentResponse,
5357
CreateIssueResponse,
5458
IssueDetailsResponse,
59+
IssueLabelResponse,
5560
IssueLabelsResponse,
5661
IssueResponse,
5762
IssuesResponse,
@@ -83,6 +88,12 @@ def _to_int(value: str | None) -> int | None:
8388
return None
8489

8590

91+
def _first_node(data: dict[str, Any], key: str) -> dict[str, Any] | None:
92+
"""Return the first node of a connection in ``data[key]``, or ``None``."""
93+
nodes = (data.get(key) or {}).get("nodes") or []
94+
return nodes[0] if nodes else None
95+
96+
8697
class LinearClient:
8798
"""Client for Linear's GraphQL API.
8899
@@ -556,17 +567,66 @@ def find_workflow_state(self, request: FindWorkflowStateRequest) -> WorkflowStat
556567
A [`WorkflowStateResponse`][linear_python_client.WorkflowStateResponse];
557568
`.state` is `None` if no state matches.
558569
"""
559-
variables = {
560-
"first": 1,
561-
"after": None,
562-
"filter": {
563-
"team": {"id": {"eq": request.team_id}},
564-
"name": {"eqIgnoreCase": request.name},
565-
},
570+
filter_ = {
571+
"team": {"id": {"eq": request.team_id}},
572+
"name": {"eqIgnoreCase": request.name},
566573
}
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})
574+
data = self.execute(queries.WORKFLOW_STATES, {"first": 1, "filter": filter_})
575+
return WorkflowStateResponse.model_validate({"state": _first_node(data, "workflowStates")})
576+
577+
def find_team(self, request: FindTeamRequest) -> TeamResponse:
578+
"""Resolve a team by display name or key.
579+
580+
Args:
581+
request: A [`FindTeamRequest`][linear_python_client.FindTeamRequest]
582+
with `name` and/or `key`.
583+
584+
Returns:
585+
A [`TeamResponse`][linear_python_client.TeamResponse]; `.team` is
586+
`None` if no team matches.
587+
"""
588+
data = self.execute(queries.TEAMS, {"first": 1, "filter": request.to_filter()})
589+
return TeamResponse.model_validate({"team": _first_node(data, "teams")})
590+
591+
def find_user(self, request: FindUserRequest) -> UserResponse:
592+
"""Resolve a user by name, display name, or email.
593+
594+
Args:
595+
request: A [`FindUserRequest`][linear_python_client.FindUserRequest]
596+
with `name` and/or `email`.
597+
598+
Returns:
599+
A [`UserResponse`][linear_python_client.UserResponse]; `.user` is
600+
`None` if no user matches.
601+
"""
602+
data = self.execute(queries.USERS, {"first": 1, "filter": request.to_filter()})
603+
return UserResponse.model_validate({"user": _first_node(data, "users")})
604+
605+
def find_project(self, request: FindProjectRequest) -> ProjectResponse:
606+
"""Resolve a project by name (case-insensitive).
607+
608+
Args:
609+
request: A [`FindProjectRequest`][linear_python_client.FindProjectRequest].
610+
611+
Returns:
612+
A [`ProjectResponse`][linear_python_client.ProjectResponse]; `.project`
613+
is `None` if no project matches.
614+
"""
615+
data = self.execute(queries.PROJECTS, {"first": 1, "filter": request.to_filter()})
616+
return ProjectResponse.model_validate({"project": _first_node(data, "projects")})
617+
618+
def find_label(self, request: FindLabelRequest) -> IssueLabelResponse:
619+
"""Resolve an issue label by name, optionally scoped to a team.
620+
621+
Args:
622+
request: A [`FindLabelRequest`][linear_python_client.FindLabelRequest].
623+
624+
Returns:
625+
An [`IssueLabelResponse`][linear_python_client.IssueLabelResponse];
626+
`.label` is `None` if no label matches.
627+
"""
628+
data = self.execute(queries.ISSUE_LABELS, {"first": 1, "filter": request.to_filter()})
629+
return IssueLabelResponse.model_validate({"label": _first_node(data, "issueLabels")})
570630

571631
def issue_labels(self, request: IssueLabelsRequest | None = None) -> IssueLabelsResponse:
572632
"""List issue labels in the workspace.

src/linear_python_client/models/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
CommentCreateRequest,
2626
CommentRequest,
2727
CommentsRequest,
28+
FindLabelRequest,
29+
FindProjectRequest,
30+
FindTeamRequest,
31+
FindUserRequest,
2832
FindWorkflowStateRequest,
2933
IssueAddLabelRequest,
3034
IssueArchiveRequest,
@@ -53,6 +57,7 @@
5357
CreateCommentResponse,
5458
CreateIssueResponse,
5559
IssueDetailsResponse,
60+
IssueLabelResponse,
5661
IssueLabelsResponse,
5762
IssueResponse,
5863
IssuesResponse,
@@ -99,6 +104,10 @@
99104
"IssueRemoveLabelRequest",
100105
"IssueSetStateRequest",
101106
"FindWorkflowStateRequest",
107+
"FindTeamRequest",
108+
"FindUserRequest",
109+
"FindLabelRequest",
110+
"FindProjectRequest",
102111
"ProjectRequest",
103112
"ProjectsRequest",
104113
"CommentRequest",
@@ -128,5 +137,6 @@
128137
"CreateCommentResponse",
129138
"WorkflowStateResponse",
130139
"WorkflowStatesResponse",
140+
"IssueLabelResponse",
131141
"IssueLabelsResponse",
132142
]

0 commit comments

Comments
 (0)