Skip to content

Commit 218e9e1

Browse files
author
SUY
committed
linear: resolve team_id from key or name, not just UUID
create_issue/create_project/update_issue/search_issues/list_projects now accept a team's short key (ENG) or name for team_id and resolve it to the UUID via a teams lookup before the request. UUID values skip the lookup (no extra round-trip); an unresolvable reference fails clearly and lists the available teams.
1 parent 70c35a9 commit 218e9e1

3 files changed

Lines changed: 280 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- `linear``team_id` on `create_issue`, `create_project`,
12+
`update_issue`, `search_issues`, and `list_projects` now accepts the
13+
team's short key (e.g. `ENG`) or name in addition to its UUID.
14+
Non-UUID references are resolved to the UUID via a teams lookup before
15+
the request; an unresolvable reference fails clearly and lists the
16+
available teams. UUID values skip the lookup entirely (no extra
17+
round-trip).
18+
919
## [0.10.0] - 2026-06-19
1020

1121
### Changed (schema)

src/modulex_integrations/tools/linear/tests/test_linear.py

Lines changed: 139 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828

2929
API = "https://api.linear.app/graphql"
3030
_API_KEY = "lin_api_fake"
31+
# A canonical 8-4-4-4-12 UUID — the shape Linear's API requires for teamId.
32+
# Passing this bypasses team resolution (no teams lookup round-trip).
33+
_TEAM_UUID = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
3134

3235

3336
def _args(**extra: Any) -> dict[str, Any]:
@@ -38,6 +41,13 @@ def _gql(body: dict[str, Any]) -> dict[str, Any]:
3841
return body
3942

4043

44+
def _request_bodies(httpx_mock: Any) -> list[dict[str, Any]]:
45+
"""Decode every captured request's JSON body, in order."""
46+
import json
47+
48+
return [json.loads(r.content.decode()) for r in httpx_mock.get_requests()]
49+
50+
4151
class TestManifest:
4252
def test_manifest_exposes_seven_actions(self) -> None:
4353
assert len(manifest.actions) == 7
@@ -193,23 +203,28 @@ async def test_search_issues_filter_is_a_variable_not_interpolated(
193203
)
194204
result = SearchIssuesOutput.model_validate(
195205
await search_issues.ainvoke(
196-
_args(team_id="T1", query="bug", label_names=["urgent", "bug"], limit=10)
206+
_args(
207+
team_id=_TEAM_UUID,
208+
query="bug",
209+
label_names=["urgent", "bug"],
210+
limit=10,
211+
)
197212
)
198213
)
199214
assert result.success is True
200215
assert result.count == 1
201216
# The filter is sent as a typed $filter variable, not spliced into the query.
202217
assert captured["variables"]["filter"] == {
203218
"title": {"containsIgnoreCase": "bug"},
204-
"team": {"id": {"eq": "T1"}},
219+
"team": {"id": {"eq": _TEAM_UUID}},
205220
"labels": {"name": {"in": ["urgent", "bug"]}},
206221
}
207222
assert captured["variables"]["first"] == 10
208223
assert captured["variables"]["includeArchived"] is False
209224
assert captured["variables"]["orderBy"] == "updatedAt"
210225
# User-supplied filter values never appear in the query text.
211226
assert "bug" not in captured["query"]
212-
assert "T1" not in captured["query"]
227+
assert _TEAM_UUID not in captured["query"]
213228
assert "$filter: IssueFilter" in captured["query"]
214229

215230

@@ -245,13 +260,121 @@ async def test_create_issue(httpx_mock: Any) -> None:
245260
},
246261
)
247262
result = CreateIssueOutput.model_validate(
248-
await create_issue.ainvoke(_args(team_id="T1", title="New bug", priority=2))
263+
await create_issue.ainvoke(
264+
_args(team_id=_TEAM_UUID, title="New bug", priority=2)
265+
)
249266
)
250267
assert result.success is True
251268
assert result.issue is not None
252269
assert result.issue["identifier"] == "BE-99"
253270

254271

272+
@pytest.mark.asyncio
273+
async def test_create_issue_resolves_team_key(httpx_mock: Any) -> None:
274+
"""Passing a team KEY (or name) resolves to the team UUID, and the
275+
issueCreate mutation receives the UUID — not the raw key. This is the
276+
fix for 'teamId must be a UUID' when users pass the human-facing key."""
277+
# First request: the team-resolution lookup.
278+
httpx_mock.add_response(
279+
method="POST",
280+
url=API,
281+
json={
282+
"data": {
283+
"teams": {
284+
"nodes": [
285+
{
286+
"id": "11111111-1111-4111-8111-111111111111",
287+
"name": "Frontend",
288+
"key": "FE",
289+
},
290+
{"id": _TEAM_UUID, "name": "Engineering", "key": "ENG"},
291+
],
292+
"pageInfo": {"hasNextPage": False, "endCursor": None},
293+
}
294+
}
295+
},
296+
)
297+
# Second request: the create mutation.
298+
httpx_mock.add_response(
299+
method="POST",
300+
url=API,
301+
json={
302+
"data": {
303+
"issueCreate": {
304+
"success": True,
305+
"issue": {"id": "I1", "identifier": "ENG-1", "title": "x"},
306+
}
307+
}
308+
},
309+
)
310+
# Lowercase 'eng' also proves the match is case-insensitive against 'ENG'.
311+
result = CreateIssueOutput.model_validate(
312+
await create_issue.ainvoke(_args(team_id="eng", title="x"))
313+
)
314+
assert result.success is True
315+
assert result.issue is not None
316+
assert result.issue["identifier"] == "ENG-1"
317+
318+
bodies = _request_bodies(httpx_mock)
319+
assert len(bodies) == 2
320+
# The mutation carried the resolved UUID, never the raw 'eng' key.
321+
assert bodies[1]["variables"]["input"]["teamId"] == _TEAM_UUID
322+
323+
324+
@pytest.mark.asyncio
325+
async def test_create_issue_unknown_team_lists_available(httpx_mock: Any) -> None:
326+
"""An unresolvable team reference fails clearly and lists the available
327+
teams, rather than firing a doomed create mutation."""
328+
httpx_mock.add_response(
329+
method="POST",
330+
url=API,
331+
json={
332+
"data": {
333+
"teams": {
334+
"nodes": [
335+
{"id": _TEAM_UUID, "name": "Engineering", "key": "ENG"},
336+
],
337+
"pageInfo": {"hasNextPage": False, "endCursor": None},
338+
}
339+
}
340+
},
341+
)
342+
result = CreateIssueOutput.model_validate(
343+
await create_issue.ainvoke(_args(team_id="NOPE", title="x"))
344+
)
345+
assert result.success is False
346+
assert result.error is not None
347+
assert "No Linear team matches" in result.error
348+
assert "ENG" in result.error # available teams are listed for the caller
349+
# Only the resolution lookup happened; no create mutation was sent.
350+
assert len(httpx_mock.get_requests()) == 1
351+
352+
353+
@pytest.mark.asyncio
354+
async def test_create_issue_uuid_skips_resolution(httpx_mock: Any) -> None:
355+
"""A UUID team_id is forwarded directly — no teams lookup round-trip."""
356+
httpx_mock.add_response(
357+
method="POST",
358+
url=API,
359+
json={
360+
"data": {
361+
"issueCreate": {
362+
"success": True,
363+
"issue": {"id": "I1", "identifier": "ENG-1", "title": "x"},
364+
}
365+
}
366+
},
367+
)
368+
result = CreateIssueOutput.model_validate(
369+
await create_issue.ainvoke(_args(team_id=_TEAM_UUID, title="x"))
370+
)
371+
assert result.success is True
372+
# Exactly one request — the mutation. Resolution was skipped.
373+
assert len(httpx_mock.get_requests()) == 1
374+
body = _request_bodies(httpx_mock)[0]
375+
assert body["variables"]["input"]["teamId"] == _TEAM_UUID
376+
377+
255378
@pytest.mark.asyncio
256379
async def test_create_issue_mutation_failure(httpx_mock: Any) -> None:
257380
httpx_mock.add_response(
@@ -260,7 +383,7 @@ async def test_create_issue_mutation_failure(httpx_mock: Any) -> None:
260383
json={"data": {"issueCreate": {"success": False, "issue": None}}},
261384
)
262385
result = CreateIssueOutput.model_validate(
263-
await create_issue.ainvoke(_args(team_id="T1", title="x"))
386+
await create_issue.ainvoke(_args(team_id=_TEAM_UUID, title="x"))
264387
)
265388
assert result.success is False
266389
assert result.error is not None and "create" in result.error
@@ -270,9 +393,10 @@ async def test_create_issue_mutation_failure(httpx_mock: Any) -> None:
270393
async def test_create_issue_argument_validation_surfaces_reason(
271394
httpx_mock: Any,
272395
) -> None:
273-
"""A team KEY passed as team_id yields Linear's generic 'Argument
274-
Validation Error'; the real reason lives in extensions and must be
275-
surfaced so the failure is debuggable."""
396+
"""When the mutation itself returns Linear's generic 'Argument
397+
Validation Error', the real reason lives in extensions and must be
398+
surfaced so the failure is debuggable. (team_id is a UUID here, so
399+
resolution is skipped and the request reaches the mutation.)"""
276400
httpx_mock.add_response(
277401
method="POST",
278402
url=API,
@@ -285,21 +409,21 @@ async def test_create_issue_argument_validation_surfaces_reason(
285409
"extensions": {
286410
"code": "INTERNAL_SERVER_ERROR",
287411
"type": "invalid_input",
288-
"userPresentableMessage": "teamId must be a UUID",
412+
"userPresentableMessage": "dueDate must be a valid date",
289413
"userError": True,
290414
},
291415
}
292416
],
293417
},
294418
)
295419
result = CreateIssueOutput.model_validate(
296-
await create_issue.ainvoke(_args(team_id="ENG", title="x"))
420+
await create_issue.ainvoke(_args(team_id=_TEAM_UUID, title="x"))
297421
)
298422
assert result.success is False
299423
assert result.error is not None
300424
# Generic wrapper preserved, actionable reason appended.
301425
assert "Argument Validation Error" in result.error
302-
assert "teamId must be a UUID" in result.error
426+
assert "dueDate must be a valid date" in result.error
303427

304428

305429
@pytest.mark.asyncio
@@ -333,7 +457,7 @@ async def test_graphql_error_falls_back_to_validation_constraints(
333457
},
334458
)
335459
result = CreateIssueOutput.model_validate(
336-
await create_issue.ainvoke(_args(team_id="T1", title="x", priority=9))
460+
await create_issue.ainvoke(_args(team_id=_TEAM_UUID, title="x", priority=9))
337461
)
338462
assert result.success is False
339463
assert result.error is not None
@@ -352,7 +476,7 @@ async def test_graphql_error_without_extensions_is_unchanged(
352476
json={"errors": [{"message": "Authentication required"}]},
353477
)
354478
result = CreateIssueOutput.model_validate(
355-
await create_issue.ainvoke(_args(team_id="T1", title="x"))
479+
await create_issue.ainvoke(_args(team_id=_TEAM_UUID, title="x"))
356480
)
357481
assert result.success is False
358482
assert result.error == "GraphQL errors: Authentication required"
@@ -404,7 +528,7 @@ async def test_list_projects(httpx_mock: Any) -> None:
404528
},
405529
)
406530
result = ListProjectsOutput.model_validate(
407-
await list_projects.ainvoke(_args(team_id="T1", limit=5))
531+
await list_projects.ainvoke(_args(team_id=_TEAM_UUID, limit=5))
408532
)
409533
assert result.success is True
410534
assert result.count == 1
@@ -426,7 +550,7 @@ async def test_create_project(httpx_mock: Any) -> None:
426550
},
427551
)
428552
result = CreateProjectOutput.model_validate(
429-
await create_project.ainvoke(_args(team_id="T1", name="New project"))
553+
await create_project.ainvoke(_args(team_id=_TEAM_UUID, name="New project"))
430554
)
431555
assert result.success is True
432556
assert result.project is not None

0 commit comments

Comments
 (0)