From 642fb59169cb42293c582a13855bc37ff07ec05d Mon Sep 17 00:00:00 2001 From: Liz Zhang Date: Tue, 19 May 2026 22:02:33 -0700 Subject: [PATCH] feat(skills): resolve --label and --assignee names in Linear create/update-issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linear CLI's create-issue handler ignored its declared `--label` and `--assignee` flags, with a `TODO: label + assignee name->id lookup (omitted for v1 brevity)` comment at linear_api.py:232 acknowledging the gap. update- issue didn't expose the flags at all. Users had to hand-pluck Linear UUIDs. This change adds two resolver helpers that mirror the existing `_resolve_team_id` shape and the state-name lookup already used by update-status (lines 261-284): - `_resolve_label_ids(team_id, names)` — labels are team-scoped, queried via `team(id) { labels { ... } }`. Case-insensitive, comma-separated names accepted at the CLI surface. - `_resolve_user_id(name_or_email)` — matches against `name`, `displayName`, or `email` from the workspace `users` query. Case-insensitive. Tolerates null `displayName`/`email` (Linear sometimes returns these). Both `create-issue` and `update-issue` now wire these in. `update-issue` fetches the issue's team id first (one extra query) so label resolution stays correctly team-scoped. The SKILL.md Python-helper section documents the new human-readable flag usage. Tests: `tests/skills/test_linear_skill.py` — 17 cases covering single/multi/ case-insensitive resolution, not-found exit semantics, null-field tolerance, both command wirings, the `update-issue --title only` fast path that must skip the team lookup, and `--label ',Bug,,'` empty-segment parsing. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/productivity/linear/SKILL.md | 7 + .../productivity/linear/scripts/linear_api.py | 79 +++- tests/skills/test_linear_skill.py | 347 ++++++++++++++++++ 3 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 tests/skills/test_linear_skill.py diff --git a/skills/productivity/linear/SKILL.md b/skills/productivity/linear/SKILL.md index a08a03e439e0e..b3b377dfdafcd 100644 --- a/skills/productivity/linear/SKILL.md +++ b/skills/productivity/linear/SKILL.md @@ -53,6 +53,13 @@ python3 "$SCRIPT" raw 'query { viewer { name } }' All subcommands: `whoami`, `list-teams`, `list-projects`, `list-states`, `list-issues`, `get-issue`, `search-issues`, `create-issue`, `update-issue`, `update-status`, `add-comment`, `list-documents`, `get-document`, `search-documents`, `raw`. Run with `--help` for flags. +`create-issue` and `update-issue` accept `--label` and `--assignee` as human-readable names — no UUIDs required. Labels resolve case-insensitively within the team (comma-separated for multiple); the assignee accepts name, displayName, or email. + +```bash +python3 "$SCRIPT" create-issue --team ENG --title "Fix login" --label "Bug,P1" --assignee alice@example.com +python3 "$SCRIPT" update-issue ENG-42 --label "Investigating" --assignee "Alice Smith" +``` + Use the script when: you want a quick answer without crafting GraphQL. Use curl when: you need a query the script doesn't wrap, or you want to compose filters inline. ## Workflow States diff --git a/skills/productivity/linear/scripts/linear_api.py b/skills/productivity/linear/scripts/linear_api.py index cb8c5d846dd09..43d05f9e80ffb 100644 --- a/skills/productivity/linear/scripts/linear_api.py +++ b/skills/productivity/linear/scripts/linear_api.py @@ -22,10 +22,15 @@ --team KEY Required --description DESC --priority 0-4 0=none, 1=urgent, 4=low - --label NAME - --assignee NAME + --label NAME[,NAME...] Label name(s), comma-separated; resolved within team + --assignee NAME|EMAIL User name, displayName, or email; resolved to assigneeId --parent IDENTIFIER Parent issue ID for sub-issues - update-issue [options] Update existing issue (same options as create) + update-issue [options] Update existing issue + --title TITLE New title + --description DESC New description + --priority 0-4 New priority + --label NAME[,NAME...] Replace labels (comma-separated names, resolved per team) + --assignee NAME|EMAIL Reassign by name/displayName/email update-status Move issue to workflow state (by state name) add-comment Add comment to issue @@ -131,6 +136,49 @@ def _resolve_team_id(key_or_name: str) -> str | None: return None +def _resolve_label_ids(team_id: str, names: list[str]) -> list[str]: + """Map label names to UUIDs within a team. Labels are team-scoped in Linear.""" + q = """query($id: String!) { + team(id: $id) { labels(first: 100) { nodes { id name } } } + }""" + nodes = ( + (gql(q, {"id": team_id}).get("team") or {}) + .get("labels", {}) + .get("nodes", []) + ) + by_name = {n["name"].lower(): n["id"] for n in nodes} + out: list[str] = [] + for name in names: + lid = by_name.get(name.lower()) + if not lid: + sys.stderr.write( + f"Label '{name}' not found in team. Available: " + f"{[n['name'] for n in nodes]}\n" + ) + sys.exit(1) + out.append(lid) + return out + + +def _resolve_user_id(name_or_email: str) -> str: + """Map a workspace user reference (name, displayName, or email) to UUID.""" + q = "query { users(first: 100) { nodes { id name displayName email } } }" + users = gql(q).get("users", {}).get("nodes", []) + key = name_or_email.lower() + for u in users: + if ( + (u.get("email") or "").lower() == key + or (u.get("name") or "").lower() == key + or (u.get("displayName") or "").lower() == key + ): + return u["id"] + sys.stderr.write( + f"User '{name_or_email}' not found. Available: " + f"{[u.get('name') for u in users]}\n" + ) + sys.exit(1) + + def cmd_list_projects(args: argparse.Namespace) -> None: if args.team: tid = _resolve_team_id(args.team) @@ -229,7 +277,12 @@ def cmd_create_issue(args: argparse.Namespace) -> None: inp["priority"] = args.priority if args.parent: inp["parentId"] = args.parent - # TODO: label + assignee name->id lookup (omitted for v1 brevity) + if args.label: + names = [n.strip() for n in args.label.split(",") if n.strip()] + if names: + inp["labelIds"] = _resolve_label_ids(tid, names) + if args.assignee: + inp["assigneeId"] = _resolve_user_id(args.assignee) q = """mutation($input: IssueCreateInput!) { issueCreate(input: $input) { @@ -247,6 +300,18 @@ def cmd_update_issue(args: argparse.Namespace) -> None: inp["description"] = args.description if args.priority is not None: inp["priority"] = args.priority + if args.label or args.assignee: + team_q = "query($id: String!) { issue(id: $id) { team { id } } }" + issue = gql(team_q, {"id": args.identifier}).get("issue") + if not issue: + sys.stderr.write(f"Issue not found: {args.identifier}\n") + sys.exit(1) + if args.label: + names = [n.strip() for n in args.label.split(",") if n.strip()] + if names: + inp["labelIds"] = _resolve_label_ids(issue["team"]["id"], names) + if args.assignee: + inp["assigneeId"] = _resolve_user_id(args.assignee) if not inp: sys.stderr.write("No update fields provided.\n") sys.exit(1) @@ -392,8 +457,8 @@ def build_parser() -> argparse.ArgumentParser: ci.add_argument("--team", required=True) ci.add_argument("--description") ci.add_argument("--priority", type=int, choices=[0, 1, 2, 3, 4]) - ci.add_argument("--label") - ci.add_argument("--assignee") + ci.add_argument("--label", help="Label name(s), comma-separated. Resolved to labelIds within the team.") + ci.add_argument("--assignee", help="User name, displayName, or email. Resolved to assigneeId.") ci.add_argument("--parent") ci.set_defaults(func=cmd_create_issue) @@ -402,6 +467,8 @@ def build_parser() -> argparse.ArgumentParser: ui.add_argument("--title") ui.add_argument("--description") ui.add_argument("--priority", type=int, choices=[0, 1, 2, 3, 4]) + ui.add_argument("--label", help="Label name(s), comma-separated. Resolved within the issue's team.") + ui.add_argument("--assignee", help="User name, displayName, or email. Resolved to assigneeId.") ui.set_defaults(func=cmd_update_issue) us = sub.add_parser("update-status") diff --git a/tests/skills/test_linear_skill.py b/tests/skills/test_linear_skill.py new file mode 100644 index 0000000000000..f5ff94943db42 --- /dev/null +++ b/tests/skills/test_linear_skill.py @@ -0,0 +1,347 @@ +"""Tests for the Linear skill's name->id resolution in create-issue and update-issue. + +These tests cover the resolver helpers (`_resolve_label_ids`, `_resolve_user_id`) +and the wiring inside `cmd_create_issue` / `cmd_update_issue` that consumes +them. The script is imported by path because skills/ is not on PYTHONPATH. +""" + +from __future__ import annotations + +import importlib.util +import io +import json +from pathlib import Path +from typing import Any + +import pytest + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] + / "skills/productivity/linear/scripts/linear_api.py" +) + + +@pytest.fixture +def linear(monkeypatch): + monkeypatch.setenv("LINEAR_API_KEY", "lin_api_test") + spec = importlib.util.spec_from_file_location("linear_api_test", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def _scripted_gql(responses: list[Any], record: list[tuple[str, dict]] | None = None): + """Build a fake ``gql`` that returns ``responses`` in order and records calls.""" + + iterator = iter(responses) + + def fake(query: str, variables: dict | None = None) -> Any: + if record is not None: + record.append((query, variables or {})) + try: + return next(iterator) + except StopIteration as e: + raise AssertionError( + f"gql() called more times than fixture provided. " + f"Extra call: query={query!r} variables={variables!r}" + ) from e + + return fake + + +# ---------- _resolve_label_ids ---------- + + +def test_resolve_label_ids_single(linear, monkeypatch): + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"team": {"labels": {"nodes": [ + {"id": "lbl-1", "name": "Bug"}, + {"id": "lbl-2", "name": "P1"}, + ]}}} + ]), + ) + assert linear._resolve_label_ids("team-id", ["Bug"]) == ["lbl-1"] + + +def test_resolve_label_ids_multi(linear, monkeypatch): + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"team": {"labels": {"nodes": [ + {"id": "lbl-1", "name": "Bug"}, + {"id": "lbl-2", "name": "P1"}, + {"id": "lbl-3", "name": "Frontend"}, + ]}}} + ]), + ) + assert linear._resolve_label_ids("team-id", ["Bug", "Frontend"]) == ["lbl-1", "lbl-3"] + + +def test_resolve_label_ids_case_insensitive(linear, monkeypatch): + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"team": {"labels": {"nodes": [{"id": "lbl-1", "name": "Bug"}]}}} + ]), + ) + assert linear._resolve_label_ids("team-id", ["bUg"]) == ["lbl-1"] + + +def test_resolve_label_ids_not_found_exits(linear, monkeypatch, capsys): + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"team": {"labels": {"nodes": [{"id": "lbl-1", "name": "Bug"}]}}} + ]), + ) + with pytest.raises(SystemExit) as exc: + linear._resolve_label_ids("team-id", ["Nonexistent"]) + assert exc.value.code == 1 + assert "Label 'Nonexistent' not found" in capsys.readouterr().err + + +def test_resolve_label_ids_handles_null_team(linear, monkeypatch, capsys): + """When the team query returns a null team, error cleanly instead of crashing.""" + monkeypatch.setattr(linear, "gql", _scripted_gql([{"team": None}])) + with pytest.raises(SystemExit): + linear._resolve_label_ids("nope", ["Bug"]) + assert "Label 'Bug' not found" in capsys.readouterr().err + + +# ---------- _resolve_user_id ---------- + + +def test_resolve_user_id_by_name(linear, monkeypatch): + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"users": {"nodes": [ + {"id": "u-1", "name": "Alice Smith", "displayName": "alice", "email": "alice@example.com"}, + ]}} + ]), + ) + assert linear._resolve_user_id("Alice Smith") == "u-1" + + +def test_resolve_user_id_by_email(linear, monkeypatch): + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"users": {"nodes": [ + {"id": "u-1", "name": "Alice", "displayName": "alice", "email": "alice@example.com"}, + ]}} + ]), + ) + assert linear._resolve_user_id("alice@example.com") == "u-1" + + +def test_resolve_user_id_by_display_name(linear, monkeypatch): + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"users": {"nodes": [ + {"id": "u-1", "name": "Alice Smith", "displayName": "alice", "email": "alice@example.com"}, + ]}} + ]), + ) + assert linear._resolve_user_id("Alice") == "u-1" # matches displayName "alice" (case-insensitive) + + +def test_resolve_user_id_not_found_exits(linear, monkeypatch, capsys): + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"users": {"nodes": [{"id": "u-1", "name": "Alice", "displayName": "alice", "email": "alice@example.com"}]}} + ]), + ) + with pytest.raises(SystemExit) as exc: + linear._resolve_user_id("bob@example.com") + assert exc.value.code == 1 + assert "User 'bob@example.com' not found" in capsys.readouterr().err + + +def test_resolve_user_id_handles_null_fields(linear, monkeypatch): + """Linear may return users with null displayName or email — don't crash.""" + monkeypatch.setattr( + linear, + "gql", + _scripted_gql([ + {"users": {"nodes": [ + {"id": "u-1", "name": "Alice", "displayName": None, "email": None}, + ]}} + ]), + ) + assert linear._resolve_user_id("Alice") == "u-1" + + +# ---------- cmd_create_issue wiring ---------- + + +def _ns(**kwargs): + """Minimal argparse.Namespace stand-in.""" + import argparse + defaults = { + "title": None, "team": None, "description": None, "priority": None, + "label": None, "assignee": None, "parent": None, "identifier": None, + } + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +def test_create_issue_resolves_labels_and_assignee(linear, monkeypatch, capsys): + calls: list[tuple[str, dict]] = [] + monkeypatch.setattr( + linear, + "gql", + _scripted_gql( + [ + # 1: _resolve_team_id + {"teams": {"nodes": [{"id": "team-uuid", "key": "ENG", "name": "Engineering"}]}}, + # 2: _resolve_label_ids + {"team": {"labels": {"nodes": [ + {"id": "lbl-bug", "name": "Bug"}, + {"id": "lbl-p1", "name": "P1"}, + ]}}}, + # 3: _resolve_user_id + {"users": {"nodes": [ + {"id": "user-alice", "name": "Alice", "displayName": "alice", "email": "alice@x.io"}, + ]}}, + # 4: issueCreate mutation + {"issueCreate": {"success": True, "issue": {"id": "i-1", "identifier": "ENG-1", "title": "x", "url": "https://linear/ENG-1"}}}, + ], + record=calls, + ), + ) + args = _ns(title="x", team="ENG", label="Bug,P1", assignee="alice@x.io") + linear.cmd_create_issue(args) + + # Inspect the mutation payload sent to gql (last call). + mutation_query, mutation_vars = calls[-1] + assert "issueCreate" in mutation_query + sent = mutation_vars["input"] + assert sent["teamId"] == "team-uuid" + assert sent["title"] == "x" + assert sent["labelIds"] == ["lbl-bug", "lbl-p1"] + assert sent["assigneeId"] == "user-alice" + + +def test_create_issue_without_label_or_assignee(linear, monkeypatch): + """When neither flag is set, no resolver queries are issued.""" + calls: list[tuple[str, dict]] = [] + monkeypatch.setattr( + linear, + "gql", + _scripted_gql( + [ + {"teams": {"nodes": [{"id": "team-uuid", "key": "ENG", "name": "Engineering"}]}}, + {"issueCreate": {"success": True, "issue": {"identifier": "ENG-2"}}}, + ], + record=calls, + ), + ) + args = _ns(title="y", team="ENG") + linear.cmd_create_issue(args) + sent = calls[-1][1]["input"] + assert "labelIds" not in sent + assert "assigneeId" not in sent + + +def test_create_issue_label_ignores_empty_segments(linear, monkeypatch): + """`--label ',Bug,'` should parse to `['Bug']`, not crash on the empty segments.""" + calls: list[tuple[str, dict]] = [] + monkeypatch.setattr( + linear, + "gql", + _scripted_gql( + [ + {"teams": {"nodes": [{"id": "team-uuid", "key": "ENG", "name": "Eng"}]}}, + {"team": {"labels": {"nodes": [{"id": "lbl-bug", "name": "Bug"}]}}}, + {"issueCreate": {"success": True, "issue": {"identifier": "ENG-3"}}}, + ], + record=calls, + ), + ) + args = _ns(title="z", team="ENG", label=",Bug, ,") + linear.cmd_create_issue(args) + assert calls[-1][1]["input"]["labelIds"] == ["lbl-bug"] + + +# ---------- cmd_update_issue wiring ---------- + + +def test_update_issue_resolves_via_issue_team(linear, monkeypatch): + """update-issue must look up the issue's team before resolving labels.""" + calls: list[tuple[str, dict]] = [] + monkeypatch.setattr( + linear, + "gql", + _scripted_gql( + [ + # 1: fetch issue's team id + {"issue": {"team": {"id": "team-uuid"}}}, + # 2: _resolve_label_ids in that team + {"team": {"labels": {"nodes": [{"id": "lbl-p1", "name": "P1"}]}}}, + # 3: _resolve_user_id + {"users": {"nodes": [ + {"id": "user-bob", "name": "Bob", "displayName": "bob", "email": "bob@x.io"}, + ]}}, + # 4: issueUpdate mutation + {"issueUpdate": {"success": True, "issue": {"identifier": "ENG-1"}}}, + ], + record=calls, + ), + ) + args = _ns(identifier="ENG-1", label="P1", assignee="Bob") + linear.cmd_update_issue(args) + + sent = calls[-1][1]["input"] + assert sent["labelIds"] == ["lbl-p1"] + assert sent["assigneeId"] == "user-bob" + + +def test_update_issue_unknown_issue_exits(linear, monkeypatch, capsys): + monkeypatch.setattr(linear, "gql", _scripted_gql([{"issue": None}])) + args = _ns(identifier="ENG-999", label="P1") + with pytest.raises(SystemExit) as exc: + linear.cmd_update_issue(args) + assert exc.value.code == 1 + assert "Issue not found: ENG-999" in capsys.readouterr().err + + +def test_update_issue_title_only_does_not_query_team(linear, monkeypatch): + """If only --title is given, the issue-team lookup must be skipped.""" + calls: list[tuple[str, dict]] = [] + monkeypatch.setattr( + linear, + "gql", + _scripted_gql( + [{"issueUpdate": {"success": True, "issue": {"identifier": "ENG-1"}}}], + record=calls, + ), + ) + args = _ns(identifier="ENG-1", title="new title") + linear.cmd_update_issue(args) + # Exactly one gql call: the mutation. + assert len(calls) == 1 + assert "issueUpdate" in calls[0][0] + assert calls[0][1]["input"] == {"title": "new title"} + + +def test_update_issue_no_fields_exits(linear, monkeypatch, capsys): + monkeypatch.setattr(linear, "gql", _scripted_gql([])) + args = _ns(identifier="ENG-1") + with pytest.raises(SystemExit) as exc: + linear.cmd_update_issue(args) + assert exc.value.code == 1 + assert "No update fields provided" in capsys.readouterr().err