Skip to content

Commit f76a301

Browse files
Add auto name-to-UUID resolution, LinearServerError, and smoke workflow
1 parent 5be4b49 commit f76a301

13 files changed

Lines changed: 1234 additions & 102 deletions

File tree

.github/workflows/publish.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,15 @@ jobs:
5454
name: dist
5555
path: dist/
5656

57-
publish:
57+
smoke:
5858
needs: [test, build]
59+
uses: ./.github/workflows/smoke.yml
60+
if: github.event_name == 'release'
61+
secrets:
62+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
63+
64+
publish:
65+
needs: [test, build, smoke]
5966
if: github.event_name == 'release'
6067
runs-on: ubuntu-latest
6168
environment:

.github/workflows/smoke.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Smoke Tests
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
team-id:
7+
description: "Linear team UUID for the smoke test"
8+
required: false
9+
type: string
10+
secrets:
11+
LINEAR_API_KEY:
12+
required: true
13+
workflow_dispatch:
14+
inputs:
15+
team-id:
16+
description: "Linear team UUID for the smoke test (optional, auto-detected if omitted)"
17+
required: false
18+
type: string
19+
20+
jobs:
21+
smoke:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
26+
- name: Install uv
27+
uses: astral-sh/setup-uv@v5
28+
with:
29+
enable-cache: true
30+
31+
- name: Run smoke tests
32+
env:
33+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
34+
LINEAR_TEAM_ID: ${{ inputs.team-id }}
35+
run: uv run python scripts/smoke_test.py

README.md

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@ with LinearClient() as client:
7474
issue = client.issue(IssueRequest(id="ENG-123")).issue
7575
print(issue.title, issue.state.name)
7676

77-
# Create an issue
77+
# Create an issue — team name, key, or UUID all work
7878
created = client.create_issue(
7979
IssueCreateRequest(
80-
team_id="9cfb482a-81e3-4154-b5b9-2c805e70a02d",
80+
team_id="Engineering",
8181
title="New exception",
8282
description="More detailed error report in **markdown**",
8383
priority=2,
@@ -162,32 +162,37 @@ for child in detail.children:
162162
print("sub-issue:", child.identifier, child.title)
163163
```
164164

165-
## Looking things up by name (instead of UUIDs)
165+
## Passing names instead of UUIDs
166166

167-
Most calls take UUIDs. Use the `find_*` resolvers to turn a human name/key/email into
168-
the entity (and its `.id`) first:
167+
`create_issue` and `update_issue` automatically resolve non-UUID strings to UUIDs, so
168+
you can pass human-readable names without a separate lookup:
169169

170170
```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
171+
from linear_python_client import IssueCreateRequest
179172

180173
client.create_issue(IssueCreateRequest(
181-
team_id=team.id,
174+
team_id="Engineering", # team display name, or "ENG" for the key
182175
title="New issue",
183-
assignee_id=assignee.id,
184-
label_ids=[bug.id],
176+
assignee_id="alice@example.com", # email, or display name
177+
label_ids=["bug", "urgent"], # label names
178+
project_id="Roadmap", # project name
179+
state_id="In Progress", # workflow state name (create only)
185180
))
186181
```
187182

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.
183+
UUID values are passed through untouched. For everything else, the `find_*` resolvers
184+
turn a name/key/email into the entity (and its `.id`) explicitly:
185+
186+
```python
187+
from linear_python_client import FindTeamRequest, FindUserRequest, FindLabelRequest
188+
189+
team = client.find_team(FindTeamRequest(key="RAV")).team # or name="Ravens"
190+
user = client.find_user(FindUserRequest(name="Elijah Winter")).user # or email=...
191+
bug = client.find_label(FindLabelRequest(name="bug", team_id=team.id)).label
192+
```
193+
194+
Each resolver returns the entity or `None`. Name matching is case-insensitive; team
195+
`key` is exact.
191196

192197
## Escape hatch: raw GraphQL
193198

@@ -212,18 +217,26 @@ All exceptions subclass `LinearError`:
212217

213218
| Exception | Raised when |
214219
|-----------|-------------|
215-
| `LinearAuthenticationError` | Credentials are rejected (HTTP 401/403 or auth error code) |
216-
| `LinearRateLimitError` | A rate limit is hit (`RATELIMITED`); carries the `X-RateLimit-*` header values |
220+
| `LinearAuthenticationError` | Credentials are rejected (HTTP 401/403, or `AUTHENTICATION_ERROR` / `UNAUTHENTICATED` / `FORBIDDEN` error code) |
221+
| `LinearRateLimitError` | A rate limit is hit; carries all `X-RateLimit-*` header values including endpoint-level limits |
217222
| `LinearGraphQLError` | The API returns GraphQL `errors`; exposes `.errors` and `.code` |
218-
| `LinearNetworkError` | The request never produced a usable response |
223+
| `LinearNetworkError` | The request never produced a usable response (connection error, non-JSON body) |
224+
| `LinearServerError` | Linear returned HTTP 5xx; exposes `.status_code` and `.body_preview` |
225+
226+
Error messages include the error code, Linear's `userPresentableMessage`, and any
227+
field-level validation details.
219228

220229
```python
221-
from linear_python_client import LinearClient, LinearRateLimitError, IssuesRequest
230+
from linear_python_client import LinearClient, LinearRateLimitError, LinearServerError, IssuesRequest
222231

223232
try:
224233
client.issues(IssuesRequest(first=100))
225234
except LinearRateLimitError as exc:
226235
print("Rate limited; resets at", exc.requests_reset)
236+
if exc.endpoint_name:
237+
print(f" endpoint {exc.endpoint_name!r}: {exc.endpoint_requests_remaining} remaining")
238+
except LinearServerError as exc:
239+
print(f"Linear server error HTTP {exc.status_code}")
227240
```
228241

229242
## Available client methods
@@ -281,19 +294,28 @@ prints after each run — add `--cov-report=html` for an annotated HTML report i
281294

282295
### Live smoke test
283296

284-
`scripts/smoke_test.py` exercises **every** client method against the real Linear API
285-
and, after each mutation, re-pulls the issue to confirm the change landed (create →
286-
update → set status → add/remove label → comment → full details). It creates one
287-
clearly-labelled test issue and archives it at the end, so it cleans up after itself.
297+
`scripts/smoke_test.py` exercises **every** client method against the real Linear API.
298+
It covers:
299+
300+
- All read-only endpoints (viewer, users, teams, projects, labels, states, comments, pagination)
301+
- Resolvers both ways: `find_*` by UUID **and** by name/key/email
302+
- Auto-resolution end-to-end: `create_issue` with team name, assignee name, and label names
303+
- `issue()` by UUID **and** by human identifier (e.g. `"ENG-123"`)
304+
- All mutations (create → update → set status → add/remove label → comment → full details)
305+
306+
After each mutation it re-pulls the issue to confirm the change landed. It creates
307+
clearly-labelled test issues and archives them at the end.
288308

289309
```sh
290310
LINEAR_API_KEY=lin_api_... uv run python scripts/smoke_test.py
291311
# optionally pin the team (defaults to the first one):
292312
LINEAR_API_KEY=... LINEAR_TEAM_ID=<uuid> uv run python scripts/smoke_test.py
293313
```
294314

295-
It prints a ✓/✗ per check and exits non-zero if any fail. Because it writes to your
296-
workspace, it's a manual script — it is not part of `pytest`.
315+
It prints a ✓/✗ per check and exits non-zero if any fail. The smoke test is also run
316+
automatically as part of the release pipeline via
317+
[`.github/workflows/smoke.yml`](.github/workflows/smoke.yml), which can be triggered
318+
manually via `workflow_dispatch` or is called by `publish.yml` on every release.
297319

298320
### Building & releasing
299321

@@ -307,8 +329,10 @@ uvx twine check dist/* # validate metadata / README rendering
307329
Releases are automated by [`.github/workflows/publish.yml`](.github/workflows/publish.yml).
308330
On every push and PR it lints, tests (with the coverage gate), builds the sdist + wheel,
309331
validates the metadata, and smoke-tests that the wheel installs and imports. When a
310-
**GitHub Release is published**, it additionally publishes the build to PyPI — after
311-
which `pip install linear-python-client` and `uv add linear-python-client` work.
332+
**GitHub Release is published**, it additionally runs the live smoke test suite against
333+
the real Linear API (requires a `LINEAR_API_KEY` repository secret) and then publishes
334+
to PyPI — only if all checks pass. After publishing, `pip install linear-python-client`
335+
and `uv add linear-python-client` work.
312336

313337
Publishing uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
314338
(OIDC), so no API token or secret is stored. One-time setup:

docs/api/errors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ Every exception raised by the client subclasses
1313
- LinearRateLimitError
1414
- LinearGraphQLError
1515
- LinearNetworkError
16+
- LinearServerError

docs/index.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@ Built against the [Linear developer docs](https://linear.app/developers).
1818
- **Pydantic models.** Snake_case attributes with camelCase aliases; validation and
1919
serialisation come for free.
2020
- **Both auth methods.** Personal API keys and OAuth 2.0 access tokens.
21+
- **Names instead of UUIDs.** Pass a team name, user email, or label name directly to
22+
`create_issue` / `update_issue` — UUIDs are resolved automatically.
2123
- **Pagination made trivial.** `paginate()` follows the cursor for you.
22-
- **Real error handling.** Auth, rate-limit, and GraphQL errors map to typed
23-
exceptions.
24+
- **Real error handling.** Auth, rate-limit, server (5xx), and GraphQL errors map to
25+
typed exceptions with full context.
2426
- **No lock-in.** `execute()` runs any raw GraphQL query or mutation.
2527

2628
## Install
@@ -43,7 +45,7 @@ with LinearClient(api_key="lin_api_...") as client:
4345

4446
created = client.create_issue(
4547
IssueCreateRequest(
46-
team_id="9cfb482a-81e3-4154-b5b9-2c805e70a02d",
48+
team_id="Engineering", # name, key, or UUID — all accepted
4749
title="New exception",
4850
priority=2,
4951
)

docs/usage.md

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,30 @@ Any field accepted by Linear's `IssueCreateInput` / `IssueUpdateInput` can be pa
160160
as an extra keyword argument using its camelCase API name (e.g. `dueDate="2026-01-01"`),
161161
even if it isn't an explicit field on the request model.
162162

163+
### Passing names instead of UUIDs
164+
165+
`create_issue` automatically resolves non-UUID strings to UUIDs before sending the
166+
request, so you can pass human-readable names directly without a separate `find_*` call:
167+
168+
```python
169+
created = client.create_issue(
170+
IssueCreateRequest(
171+
team_id="Engineering", # team display name (or "ENG" for the key)
172+
title="New exception",
173+
assignee_id="alice@example.com", # email, or display name
174+
label_ids=["bug", "urgent"], # label names
175+
project_id="Roadmap", # project name
176+
state_id="In Progress", # workflow state name
177+
)
178+
)
179+
```
180+
181+
UUID values are passed through untouched, so you can freely mix UUIDs and names.
182+
183+
`update_issue` resolves `assignee_id`, `project_id`, and `label_ids` the same way.
184+
`state_id` in an update requires a UUID (no team context is available to look up a
185+
state by name; use `find_workflow_state` first if you only have the name).
186+
163187
## Labels
164188

165189
`update_issue(IssueUpdateRequest(id=..., label_ids=[...]))` replaces an issue's whole
@@ -216,16 +240,20 @@ labels = client.issue_labels(IssueLabelsRequest(first=100))
216240

217241
## Resolving names to UUIDs
218242

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`;
243+
For `create_issue` and `update_issue`, the client resolves non-UUID strings
244+
automatically — see [Passing names instead of UUIDs](#passing-names-instead-of-uuids)
245+
above.
246+
247+
For everything else, use the `find_*` resolvers to turn a human name/key/email into
248+
an entity (read `.id` to pass elsewhere). Each returns the matching entity or `None`;
221249
name matching is case-insensitive, team `key` is exact.
222250

223251
```python
224252
from linear_python_client import (
225253
FindTeamRequest, FindUserRequest, FindProjectRequest, FindLabelRequest,
226254
)
227255

228-
team = client.find_team(FindTeamRequest(key="RAV")).team # or name="Ravens"
256+
team = client.find_team(FindTeamRequest(key="RAV")).team # or name="Ravens"
229257
user = client.find_user(FindUserRequest(name="Elijah Winter")).user # or email="..."
230258
project = client.find_project(FindProjectRequest(name="Roadmap")).project
231259
bug = client.find_label(FindLabelRequest(name="bug", team_id=team.id)).label
@@ -259,24 +287,51 @@ All exceptions subclass [`LinearError`][linear_python_client.LinearError]:
259287

260288
| Exception | Raised when |
261289
|-----------|-------------|
262-
| [`LinearAuthenticationError`][linear_python_client.LinearAuthenticationError] | Credentials are rejected (HTTP 401/403 or an auth error code) |
263-
| [`LinearRateLimitError`][linear_python_client.LinearRateLimitError] | A rate limit is hit (`RATELIMITED`); carries the `X-RateLimit-*` header values |
290+
| [`LinearAuthenticationError`][linear_python_client.LinearAuthenticationError] | Credentials are rejected (HTTP 401/403, or `AUTHENTICATION_ERROR` / `UNAUTHENTICATED` / `FORBIDDEN` error code) |
291+
| [`LinearRateLimitError`][linear_python_client.LinearRateLimitError] | A rate limit is hit (`RATELIMITED`); carries all `X-RateLimit-*` header values including endpoint-level limits |
264292
| [`LinearGraphQLError`][linear_python_client.LinearGraphQLError] | The API returns GraphQL `errors`; exposes `.errors` and `.code` |
265-
| [`LinearNetworkError`][linear_python_client.LinearNetworkError] | The request never produced a usable response |
293+
| [`LinearNetworkError`][linear_python_client.LinearNetworkError] | The request never produced a usable response (connection error, non-JSON body) |
294+
| [`LinearServerError`][linear_python_client.LinearServerError] | Linear returned an HTTP 5xx response; exposes `.status_code` and `.body_preview` |
295+
296+
Error messages include the error code, Linear's `userPresentableMessage` when
297+
available, and any field-level validation details from `extensions.errors`.
266298

267299
```python
268-
from linear_python_client import LinearClient, LinearRateLimitError, IssuesRequest
300+
from linear_python_client import (
301+
LinearClient,
302+
LinearRateLimitError,
303+
LinearServerError,
304+
IssuesRequest,
305+
)
269306

270307
try:
271308
client.issues(IssuesRequest(first=100))
272309
except LinearRateLimitError as exc:
273310
print("Rate limited; resets at", exc.requests_reset)
311+
# Endpoint-specific limit (e.g. issueCreate has a tighter cap):
312+
if exc.endpoint_name:
313+
print(f" endpoint {exc.endpoint_name!r}: {exc.endpoint_requests_remaining} remaining")
314+
except LinearServerError as exc:
315+
print(f"Linear server error HTTP {exc.status_code}: {exc.body_preview}")
274316
```
275317

276318
## Rate limits
277319

278320
Linear allows roughly **5,000 requests/hour** for API keys and OAuth apps, with a
279-
separate complexity budget. The client surfaces the relevant `X-RateLimit-*` header
280-
values on [`LinearRateLimitError`][linear_python_client.LinearRateLimitError] when a limit is
281-
hit. See the [rate limiting docs](https://linear.app/developers/rate-limiting) for the
282-
full details.
321+
separate complexity budget. Some endpoints have their own tighter per-endpoint caps.
322+
The client surfaces all `X-RateLimit-*` header values on
323+
[`LinearRateLimitError`][linear_python_client.LinearRateLimitError] when a limit is hit:
324+
325+
| Attribute | Header |
326+
|-----------|--------|
327+
| `requests_limit` | `X-RateLimit-Requests-Limit` |
328+
| `requests_remaining` | `X-RateLimit-Requests-Remaining` |
329+
| `requests_reset` | `X-RateLimit-Requests-Reset` (ms epoch) |
330+
| `query_complexity` | `X-Complexity` |
331+
| `endpoint_requests_limit` | `X-RateLimit-Endpoint-Requests-Limit` |
332+
| `endpoint_requests_remaining` | `X-RateLimit-Endpoint-Requests-Remaining` |
333+
| `endpoint_requests_reset` | `X-RateLimit-Endpoint-Requests-Reset` (ms epoch) |
334+
| `endpoint_name` | `X-RateLimit-Endpoint-Name` |
335+
336+
See the [rate limiting docs](https://linear.app/developers/rate-limiting) for the full
337+
details.

0 commit comments

Comments
 (0)