From b8a5092c2664ed8d86e2d3babe7b25e63c91b948 Mon Sep 17 00:00:00 2001 From: Magnus Hedemark Date: Wed, 5 Aug 2026 16:43:36 -0400 Subject: [PATCH] feat(linear): project mutations and richer issue verbs in CLI (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this adds Implements the request in #287 and the Tier 1 audit gaps for the `linear` skill's `scripts/linear` CLI, reconciled against the live Linear GraphQL schema. ### New verbs - `linear project update` — name, description, status, start/target dates, priority, with the same `--dry-run`/`--confirm` gate as issue mutations, and a local 255-character description guard matching Linear's `projectUpdate` limit (Linear rejects longer descriptions with a generic error). - `linear issue archive` / `linear issue unarchive` — both gated, returning `IssueArchivePayload.entity`. - `linear state list --team ENG` — first-class workflow-state discovery (previously states were only visible in the `issue move` failure path). ### Richer issue verbs - `issue create` now accepts `--project`, `--parent`, `--assignee`, `--label` (repeatable), `--state`, `--due`. - `issue update` now accepts `--assignee`, `--label` (add), `--remove-label`, `--due`, `--project`. ### Resolution rules (all require exactly one match, mirroring `resolve_team`) - Project: UUID or exact name - Parent: issue identifier or UUID - Assignee: exact name, display name, or email (via `users`) - Label: exact name within the issue's team (via `team.labels`) - Workflow state: exact name within the issue's team (existing `team.states` resolver, now reusable for `--state` on create) - Project status: exact name or type (via `projectStatuses`) ### Docs, tests, evals - SKILL.md command map, state-change gate, and error/recovery sections; README; `domain-and-workflows.md` (project semantics + 255-char limit), `graphql-contract.md` (resolution queries), `integration-boundaries.md` (intentional exclusions list), `sources.md` (2026-08-05 schema re-verification note). - 15 new offline tests (45 total) covering resolution, gates, dry-run intent, payload shapes, and field guards. - Added a sixth eval case (`safe-project-and-issue-mutations`). ## Validation - `python3 -m unittest linear/tests/test_linear.py` — 45/45 pass - `python3 scripts/validate-evals.py`, `ruby scripts/validate-skills.rb`, `python3 scripts/check-artifacts.py`, `python3 scripts/eval-coverage.py --modified-from origin/main`, skill-quality validator, marketplace/codex/llms freshness, jscpd — all green locally Closes #287 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- linear/README.md | 7 +- linear/SKILL.md | 26 +- linear/evals/evals.json | 12 + linear/references/domain-and-workflows.md | 26 +- linear/references/graphql-contract.md | 15 +- linear/references/integration-boundaries.md | 9 + linear/references/sources.md | 6 + linear/scripts/linear | 371 +++++++++++++- linear/tests/test_linear.py | 522 +++++++++++++++++++- 9 files changed, 950 insertions(+), 44 deletions(-) diff --git a/linear/README.md b/linear/README.md index 15850c2..52e4a26 100644 --- a/linear/README.md +++ b/linear/README.md @@ -3,8 +3,9 @@ ## Why Install This Skill Give an agent a small, predictable way to work with Linear without running an MCP server or -installing a package. It can find teams, projects, cycles, and issues, inspect documents, and -make carefully previewed issue changes through Linear's public API. +installing a package. It can find teams, projects, cycles, workflow states, and issues, inspect +documents, and make carefully previewed changes to issues, projects, and comments through Linear's +public API. The CLI stays intentionally narrow: it favors bounded reads, JSON output (compact with `--json`, indented without it), and dry-run previews over a large API mirror. That makes routine @@ -14,7 +15,7 @@ project-management work easier to audit. | Path | Provides | |---|---| -| `scripts/linear` | Dependency-free Python CLI for Linear GraphQL reads and issue mutations | +| `scripts/linear` | Dependency-free Python CLI for Linear GraphQL reads and confirmed mutations (issues, projects, workflow states, comments) | | `SKILL.md` | Agent workflow, safety gate, command routing, and official API links | | `tests/test_linear.py` | Offline tests for parsing, safety gates, GraphQL contracts, and dry-run behavior | | `references/` | Linear workflow, GraphQL, integration-boundary, and source guidance | diff --git a/linear/SKILL.md b/linear/SKILL.md index ecac663..4804071 100644 --- a/linear/SKILL.md +++ b/linear/SKILL.md @@ -37,11 +37,16 @@ the full command reference into a response. |---|---| | Confirm current identity | `scripts/linear whoami --json` | | Discover teams | `scripts/linear team list --limit 20 --json` | +| List a team's workflow states | `scripts/linear state list --team ENG --json` | | Narrow a known issue set | `scripts/linear issue list --team ENG --state "In Progress" --json` | | Find an issue by words | `scripts/linear issue search "customer import" --json` | | Read one known issue | `scripts/linear issue get ENG-42 --json` | | Read an issue with its project, cycle, hierarchy, comments, and relations | `scripts/linear issue get ENG-42 --detail --json` | +| Create an issue with project, parent, assignee, labels, state, or due date | `scripts/linear issue create --team ENG --title "Fix login" --project "Platform" --parent ENG-1 --assignee "Ada Lovelace" --label bug --state "In Progress" --due 2026-08-31` | +| Update an issue's assignee, labels, due date, or project | `scripts/linear issue update ENG-42 --assignee "Ada Lovelace" --label bug --remove-label p2 --due 2026-08-31 --project "Platform"` | +| Archive or unarchive an issue | `scripts/linear issue archive ENG-42` or `scripts/linear issue unarchive ENG-42` | | List or read projects | `scripts/linear project list --team ENG --json` or `scripts/linear project get "Roadmap" --json` | +| Update a project | `scripts/linear project update "Roadmap" --description "Q3 plan" --status started` | | List or read cycles | `scripts/linear cycle list --team ENG --json` or `scripts/linear cycle get UUID --json` | | Find documents by words | `scripts/linear document search roadmap --json` | | Read a document by UUID, slug, or URL | `scripts/linear document get REF --json` | @@ -54,6 +59,7 @@ the full command reference into a response. | You know an issue identifier or UUID | `issue get` | | You have words but not an identifier | `issue search` or `document search` | | You need a bounded set with filters | `issue list`, `document list`, `project list`, or `cycle list` | +| You need a team's workflow states | `state list --team ENG` | | The task needs a documented operation outside this focused CLI | `raw` with an explicit GraphQL query | `raw` is an escape hatch, not a replacement for normal commands. Keep its query narrow and use @@ -63,26 +69,32 @@ the official GraphQL documentation to confirm field names and permissions. Confirm the target, scope, and rollback path before acting. Read-only discovery may proceed without confirmation. -For `issue create`, `issue update`, `issue move`, `issue comment`, and raw GraphQL mutations: +For `issue create`, `issue update`, `issue move`, `issue comment`, `issue archive`, `issue unarchive`, +`project update`, and raw GraphQL mutations: -1. Identify the issue/team/state using a read command. +1. Identify the issue/team/project/state using a read command. 2. State the exact intended change and recovery path to the user. 3. Run the same command with `--dry-run --json`; this has no credentials or network requirement. 4. After confirmation, rerun it with `--confirm --json`. 5. Report the returned identifier and outcome without exposing credentials. The `--team` filters for issue, project, and cycle lists require an exact team key. `issue create` -resolves a team key or exact name before creation, resolves issue identifiers before comments or -updates, and resolves a destination workflow state only within that issue's team. -It does not guess IDs or workflow states. Load `references/domain-and-workflows.md` for safe -mutation recipes and Linear workflow semantics. +resolves a team key or exact name before creation, resolves issue identifiers before comments, +updates, or archiving, resolves a project by UUID or exact name, an assignee by exact name or +email, labels within the issue's team, and a destination workflow state only within that issue's +team. `project update` resolves the project by UUID or exact name and the status by name or type. +It does not guess IDs, workflow states, labels, or project statuses. Load +`references/domain-and-workflows.md` for safe mutation recipes and Linear workflow semantics. ## Errors And Recovery - Missing credentials: export one supported environment variable only for the command session, or use `--dry-run` to inspect the request. - GraphQL error: the CLI writes Linear's first useful error message to stderr and exits nonzero, including when the HTTP status is 200. Check permissions, exact identifiers, and documented field availability. - Team ambiguity: use `team list` to choose an exact key/name; do not retry by guessing an ID. -- State lookup failure: the error lists the available states for the issue's team. Use that exact name, or use documented `raw` GraphQL as the explicit escape hatch when the focused CLI cannot express the operation. +- State lookup failure: list the team's states first with `state list --team`, then use that exact name. The error also lists the available states for the issue's team. +- Label lookup failure: the error lists the available labels in the issue's team. Use that exact name. +- Project status lookup failure: the error lists the available statuses. Use an exact name or type (for example `planned`, `started`, `paused`, `completed`, `canceled`). +- Project description rejection: Linear's `projectUpdate` rejects descriptions longer than 255 characters. Keep project descriptions at 255 characters or fewer. - Limit failure: choose a value from 1 through 100. The CLI deliberately does not paginate automatically. - Rate limit or transport failure: wait and retry the same bounded read. Follow Linear's [rate limiting guidance](https://linear.app/developers/rate-limiting) rather than adding a retry loop. diff --git a/linear/evals/evals.json b/linear/evals/evals.json index 347a641..3152e77 100644 --- a/linear/evals/evals.json +++ b/linear/evals/evals.json @@ -61,6 +61,18 @@ "The response explains Linear's synchronous mutation behavior and its limits under network uncertainty", "Retry logic can deduplicate via identifiers or request tracking" ] + }, + { + "id": "safe-project-and-issue-mutations", + "prompt": "I need to rename a Linear project, move it to the 'started' status, and create an issue inside it assigned to Ada, tagged with the bug label, with a due date — without accidentally applying anything before I review it. How do I do this safely with the linear CLI, and what should I not assume about the API?", + "expected_output": "A safe mutation flow built on the linear skill's verbs: first read to confirm targets (team list, project get, state list, and issue search to check for an existing issue), then preview every write with --dry-run --json (project update with --name/--status, and issue create with --project/--assignee/--label/--due), then rerun each with --confirm --json after reviewing the preview. The response explains that friendly names are resolved only during a live run, that project statuses, workflow states, and labels are workspace- or team-defined and must be resolved by exact name rather than assumed, that due dates are ISO YYYY-MM-DD, and that Linear's projectUpdate rejects project descriptions longer than 255 characters. It also states the recovery path: issue update and issue move can revert an issue change, issue unarchive reverses an archive, and destructive deletion is deliberately not exposed as a CLI verb.", + "assertions": [ + "The flow reads before writing and checks for an existing issue first", + "Every mutation is previewed with --dry-run and applied only with --confirm", + "Project status, workflow state, and label resolution by exact name is explained, not assumed", + "The 255-character project description limit is mentioned", + "The recovery path (update/move/unarchive) and the absence of a delete verb are stated" + ] } ] } diff --git a/linear/references/domain-and-workflows.md b/linear/references/domain-and-workflows.md index 7a90a1a..d30f366 100644 --- a/linear/references/domain-and-workflows.md +++ b/linear/references/domain-and-workflows.md @@ -13,7 +13,22 @@ Triage when that feature is enabled. Priorities are numeric in the API: 0 none, Issue IDs can be UUIDs or shorthand identifiers such as `ENG-42`. Obtain object UUIDs in Linear with the command menu's “Copy model UUID” action. A parent issue groups child issues; use children -only when their work is independently actionable and trackable. +only when their work is independently actionable and trackable. Assignees resolve by exact name or +email within the workspace; labels resolve by exact name within the issue's team; due dates are ISO +dates (`YYYY-MM-DD`). `issue create` accepts `--project`, `--parent`, `--assignee`, `--label`, +`--state`, and `--due`; `issue update` accepts `--assignee`, `--label` (add), `--remove-label`, +`--due`, and `--project`. Archived issues are excluded from default reads; `issue archive` and +`issue unarchive` move an issue across that boundary and use the same confirmation gate as other +mutations. + +## Project Semantics + +Projects carry a status, a priority, start and target dates, a lead, and members. `project update` +changes the name, description, status, dates, or priority and requires the same dry-run/confirm +gate as issue mutations. Linear's `projectUpdate` rejects project descriptions longer than 255 +characters with a generic argument-validation error; keep project descriptions at 255 characters +or fewer. Project statuses are workspace-defined, so resolve the destination status by its exact +name or type inside the workspace rather than assuming a universal set. ## Choosing A Work Item @@ -22,8 +37,9 @@ then inspect the result before changing it. Use the issue identifier in follow-u is shorter and is accepted by the public API. Use a project when the user asks about a larger initiative or its status. Use a cycle when the user -asks about a team's current or planned timebox. Neither changes the mutation boundary of this CLI: -all writes remain issue-scoped. +asks about a team's current or planned timebox. The mutation boundary stays bounded: issues, +projects, workflow states, and comments are first-class verbs; cycles, teams, and documents remain +read-only, and destructive deletion stays behind `raw`. Descriptions, comments, and documents support Markdown. Plain Linear URLs to users, issues, projects, and other resources become mentions in the Linear UI. Collapsible Markdown sections use @@ -36,8 +52,8 @@ projects, and other resources become mentions in the Linear UI. Collapsible Mark 3. State the exact change and recovery path to the user. 4. Run the mutation with `--dry-run --json`; friendly references are only resolved during a live run. 5. Run the same command with `--confirm --json` only after confirmation. -6. Report the returned identifier and outcome. If a change is wrong, use `issue update` or `issue move` - to restore the prior value rather than guessing. +6. Report the returned identifier and outcome. If a change is wrong, use `issue update`, `issue move`, + `project update`, or `issue unarchive` to restore the prior value rather than guessing. Use an issue comment for a dated, issue-specific update. Use a document when the information must remain useful beyond one issue. Do not create duplicates for work that an existing issue already diff --git a/linear/references/graphql-contract.md b/linear/references/graphql-contract.md index 86e2083..bbe8ed9 100644 --- a/linear/references/graphql-contract.md +++ b/linear/references/graphql-contract.md @@ -4,10 +4,17 @@ Endpoint: `https://api.linear.app/graphql`. Send JSON GraphQL requests with `Con application/json`. A personal API key is the value of the `Authorization` header. An OAuth access token is sent in the `Authorization` header with the `Bearer ` prefix. -This CLI supports bounded reads for teams, issues, documents, projects, and cycles, plus -issue-scoped create, update, move, and comment mutations. Use `raw` for a documented operation -outside that surface. Issue reads accept a UUID or shorthand identifier. Document reads accept a -UUID, slug ID, or Linear document URL; slug lookup uses the `documents` filter. +This CLI supports bounded reads for teams, issues, documents, projects, cycles, and workflow +states, plus issue create, update, move, archive, unarchive, and comment mutations, and project +update. Use `raw` for a documented operation outside that surface. Issue reads accept a UUID or +shorthand identifier. Document reads accept a UUID, slug ID, or Linear document URL; slug lookup +uses the `documents` filter. + +Friendly-name resolution uses bounded queries: teams via the `teams` connection, users by exact +name/email via the `users` connection, labels within a team via `team.labels`, project statuses via +`projectStatuses`, and workflow states within a team via `team.states`. Each resolver requires +exactly one match and fails with the available names on ambiguity. Resolvers query at most 100 +items; a workspace or team with more matches than the first page cannot be resolved by name. For personal scripts, a personal API key is the simplest authentication method. OAuth is intended for applications acting on behalf of users; access tokens are sent with the Bearer prefix. This CLI diff --git a/linear/references/integration-boundaries.md b/linear/references/integration-boundaries.md index b844b6c..ab250e1 100644 --- a/linear/references/integration-boundaries.md +++ b/linear/references/integration-boundaries.md @@ -36,3 +36,12 @@ belongs in the focused surface only when it has a recurring agent workflow, a cl and a bounded contract. Promotion is complete only when CLI help and routing, SKILL/README command maps, official source provenance, offline request/failure tests, schema re-verification, and the repository regression gates all agree. Until then, keep the verified operation behind narrow `raw`. + +## Intentional Exclusions + +These documented operations stay behind `raw` on purpose and are not promoted to first-class +verbs: issue, project, cycle, and document deletion (irreversible by default); attachment uploads +(multipart, outside the JSON endpoint); bulk issue create/update (`issueBatchCreate`/`issueBatchUpdate`); +team, user, workflow-state, and organization administration; and integrations, webhooks, initiatives, +customers, and notifications. If a workflow needs one of these repeatedly, review the promotion +criteria above before adding a verb. diff --git a/linear/references/sources.md b/linear/references/sources.md index f4b5154..0cdce77 100644 --- a/linear/references/sources.md +++ b/linear/references/sources.md @@ -29,6 +29,12 @@ for field availability and retain the documentation URL plus access date as prov official page contains internally conflicting limit values, avoid encoding either value in CLI behavior and inspect response headers during credentialed operation. +Re-verified 2026-08-05 against the live public schema via anonymous introspection for the +mutation surface added in the project/state change: `projectUpdate` (returns `ProjectPayload.project`), +`issueArchive`/`issueUnarchive` (return `IssueArchivePayload.entity`), `team.labels`, +`projectStatuses`, `users`, and the `ProjectFilter.accessibleTeams` / `CycleFilter.team` filters. +Record the exact selection and update the offline CLI contract tests on any future change. + The CLI's offline tests verify parser, safety-gate, and error-handling contracts only. They do not replace a credentialed workspace smoke test for permissions, workspace-specific names, or returned resource data. diff --git a/linear/scripts/linear b/linear/scripts/linear index 26a3775..b4f7c77 100755 --- a/linear/scripts/linear +++ b/linear/scripts/linear @@ -16,6 +16,7 @@ MAX_LIMIT = 100 UUID_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I ) +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") ISSUE_FIELDS = "id identifier title description priority url createdAt updatedAt team { id name key } state { id name type } assignee { id name } labels { nodes { id name } }" ISSUE_DETAIL_FIELDS = ( @@ -78,6 +79,21 @@ def parse_document_ref(ref): return "slugId", ref +def parse_date(value, label): + if not DATE_RE.match(value): + fail("%s must be an ISO date in YYYY-MM-DD form" % label) + return value + + +def validate_project_description(value): + if len(value) > 255: + fail( + "project description must be 255 characters or fewer; " + "Linear's projectUpdate API rejects longer descriptions" + ) + return value + + def is_mutation(query): index = 0 braces = parens = brackets = 0 @@ -251,9 +267,9 @@ def resolve_issue(client, ref): return require_found(data.get("issue"), "issue", ref) -def resolve_state(client, issue, state_name): +def resolve_state(client, team, state_name): query = "query TeamStates($id: String!, $first: Int!) { team(id: $id) { states(first: $first) { nodes { id name type } } } }" - data = client.run(query, {"id": issue["team"]["id"], "first": MAX_LIMIT}) + data = client.run(query, {"id": team["id"], "first": MAX_LIMIT}) states = (data.get("team") or {}).get("states") or {} states = states.get("nodes", states) if isinstance(states, dict) else states matches = [state for state in states if state["name"].lower() == state_name.lower()] @@ -261,7 +277,7 @@ def resolve_state(client, issue, state_name): available = ", ".join(state["name"] for state in states) or "none" fail( "state %r did not resolve in team %s; available states: %s" - % (state_name, issue["team"]["key"], available) + % (state_name, team["key"], available) ) return matches[0] @@ -352,6 +368,59 @@ def resolve_project(client, ref): return matches[0] +def resolve_user(client, ref): + query = "query Users($first: Int!) { users(first: $first) { nodes { id name displayName email } } }" + data = client.run(query, {"first": MAX_LIMIT}) + matches = [ + user + for user in nodes(data, "users") + if user["id"] == ref + or user["name"].lower() == ref.lower() + or (user.get("displayName") or "").lower() == ref.lower() + or (user.get("email") or "").lower() == ref.lower() + ] + if len(matches) != 1: + fail( + "user %r did not resolve to exactly one user; use an exact name or email" + % ref + ) + return matches[0] + + +def resolve_label(client, team, name): + query = "query TeamLabels($id: String!, $first: Int!) { team(id: $id) { labels(first: $first) { nodes { id name } } } }" + data = client.run(query, {"id": team["id"], "first": MAX_LIMIT}) + labels = (data.get("team") or {}).get("labels") or {} + labels = labels.get("nodes", labels) if isinstance(labels, dict) else labels + matches = [label for label in labels if label["name"].lower() == name.lower()] + if len(matches) != 1: + available = ", ".join(label["name"] for label in labels) or "none" + fail( + "label %r did not resolve in team %s; available labels: %s" + % (name, team["key"], available) + ) + return matches[0] + + +def resolve_project_status(client, name): + query = "query ProjectStatuses($first: Int!) { projectStatuses(first: $first) { nodes { id name type } } }" + data = client.run(query, {"first": MAX_LIMIT}) + statuses = nodes(data, "projectStatuses") + matches = [ + status + for status in statuses + if status["name"].lower() == name.lower() + or status["type"].lower() == name.lower() + ] + if len(matches) != 1: + available = ", ".join(status["name"] for status in statuses) or "none" + fail( + "project status %r did not resolve; available statuses: %s" + % (name, available) + ) + return matches[0] + + class LinearArgumentParser(argparse.ArgumentParser): def __init__(self, *args, **kwargs): kwargs.setdefault("epilog", GLOBAL_OPTIONS_HELP) @@ -439,6 +508,25 @@ def build_parser(): p.add_argument("--title", required=True) p.add_argument("--description") p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low")) + p.add_argument( + "--project", metavar="PROJECT", help="Project UUID or exact name to attach." + ) + p.add_argument( + "--parent", metavar="ISSUE", help="Parent issue identifier or UUID for a sub-issue." + ) + p.add_argument( + "--assignee", metavar="USER", help="Assignee exact name or email." + ) + p.add_argument( + "--label", + action="append", + metavar="LABEL", + help="Label name in the issue's team (repeatable).", + ) + p.add_argument( + "--state", metavar="STATE", help="Destination workflow state name in the team." + ) + p.add_argument("--due", metavar="YYYY-MM-DD", help="Due date (ISO).") p.add_argument("--confirm", action="store_true") p = actions.add_parser( "update", @@ -449,6 +537,26 @@ def build_parser(): p.add_argument("--title") p.add_argument("--description") p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low")) + p.add_argument( + "--assignee", metavar="USER", help="Assignee exact name or email." + ) + p.add_argument( + "--label", + action="append", + metavar="LABEL", + help="Label name in the issue's team to add (repeatable).", + ) + p.add_argument( + "--remove-label", + action="append", + dest="remove_label", + metavar="LABEL", + help="Label name in the issue's team to remove (repeatable).", + ) + p.add_argument("--due", metavar="YYYY-MM-DD", help="Due date (ISO).") + p.add_argument( + "--project", metavar="PROJECT", help="Project UUID or exact name to attach." + ) p.add_argument("--confirm", action="store_true") p = actions.add_parser( "move", @@ -466,6 +574,20 @@ def build_parser(): p.add_argument("issue") p.add_argument("--body", required=True) p.add_argument("--confirm", action="store_true") + p = actions.add_parser( + "archive", + help="Archive an issue.", + description="Example: linear issue archive ENG-42", + ) + p.add_argument("issue") + p.add_argument("--confirm", action="store_true") + p = actions.add_parser( + "unarchive", + help="Unarchive an issue.", + description="Example: linear issue unarchive ENG-42", + ) + p.add_argument("issue") + p.add_argument("--confirm", action="store_true") document = root.add_parser("document", help="Read Linear documents.") actions = document.add_subparsers(dest="action", required=True) actions.add_parser( @@ -499,6 +621,24 @@ def build_parser(): description='Example: linear project get "Roadmap"', ) p.add_argument("project") + p = actions.add_parser( + "update", + help="Update a project.", + description='Example: linear project update "Roadmap" --description "Q3 plan" --status started', + ) + p.add_argument("project") + p.add_argument("--name") + p.add_argument("--description") + p.add_argument( + "--status", + help="Project status name or type (planned, started, paused, completed, canceled).", + ) + p.add_argument("--start-date", dest="start_date", help="Start date (ISO YYYY-MM-DD).") + p.add_argument( + "--target-date", dest="target_date", help="Target date (ISO YYYY-MM-DD)." + ) + p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low")) + p.add_argument("--confirm", action="store_true") cycle = root.add_parser("cycle", help="Read Linear cycles.") actions = cycle.add_subparsers(dest="action", required=True) p = actions.add_parser( @@ -513,6 +653,16 @@ def build_parser(): description="Example: linear cycle get 123e4567-e89b-12d3-a456-426614174000", ) p.add_argument("cycle") + state = root.add_parser("state", help="Read workflow states.") + actions = state.add_subparsers(dest="action", required=True) + p = actions.add_parser( + "list", + help="List workflow states for a team.", + description="Example: linear state list --team ENG --json", + ) + p.add_argument( + "--team", required=True, metavar="TEAM_KEY", help="Team key or exact name." + ) return parser @@ -544,7 +694,17 @@ def main(argv=None): args.noun == "issue" and args.action == "update" and all( - value is None for value in (args.title, args.description, args.priority) + value is None + for value in ( + args.title, + args.description, + args.priority, + args.assignee, + args.label, + args.remove_label, + args.due, + args.project, + ) ) ): fail("issue update requires at least one field to change") @@ -627,22 +787,47 @@ def main(argv=None): require_confirmation(args) if args.action == "create": if args.dry_run: - preview( - args, - [ - {"operation": "resolve team", "reference": args.team}, - { - "operation": "issueCreate", - "input": { - "team": args.team, - "title": args.title, - "description": args.description, - "priority": args.priority, - }, + operations = [ + {"operation": "resolve team", "reference": args.team} + ] + if args.project: + operations.append( + {"operation": "resolve project", "reference": args.project} + ) + if args.parent: + operations.append( + {"operation": "resolve parent issue", "reference": args.parent} + ) + if args.assignee: + operations.append( + {"operation": "resolve assignee", "reference": args.assignee} + ) + if args.label: + operations.append( + {"operation": "resolve labels", "names": args.label} + ) + if args.state: + operations.append( + {"operation": "resolve state in team", "name": args.state} + ) + operations.append( + { + "operation": "issueCreate", + "input": { + "team": args.team, + "title": args.title, + "description": args.description, + "priority": args.priority, + "project": args.project, + "parent": args.parent, + "assignee": args.assignee, + "labels": args.label, + "state": args.state, + "due": args.due, }, - ], - mutation=True, + } ) + preview(args, operations, mutation=True) return team = resolve_team(client, args.team) inp = { @@ -657,6 +842,20 @@ def main(argv=None): "low": 4, }.get(args.priority), } + if args.project: + inp["projectId"] = resolve_project(client, args.project)["id"] + if args.parent: + inp["parentId"] = resolve_issue(client, args.parent)["id"] + if args.assignee: + inp["assigneeId"] = resolve_user(client, args.assignee)["id"] + if args.label: + inp["labelIds"] = [ + resolve_label(client, team, name)["id"] for name in args.label + ] + if args.state: + inp["stateId"] = resolve_state(client, team, args.state)["id"] + if args.due: + inp["dueDate"] = parse_date(args.due, "--due") emit( client.run( "mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { %s } } }" @@ -672,6 +871,28 @@ def main(argv=None): args, ) return + if args.action in ("archive", "unarchive"): + mutation = "issueArchive" if args.action == "archive" else "issueUnarchive" + if args.dry_run: + preview( + args, + [ + {"operation": "resolve issue", "reference": args.issue}, + {"operation": mutation, "issue": args.issue}, + ], + mutation=True, + ) + return + issue = resolve_issue(client, args.issue) + emit( + client.run( + "mutation IssueArchive($id: String!) { %s(id: $id) { success entity { %s } } }" + % (mutation, ISSUE_FIELDS), + {"id": issue["id"]}, + ), + args, + ) + return if args.dry_run: operations = [{"operation": "resolve issue", "reference": args.issue}] if args.action == "move": @@ -693,6 +914,11 @@ def main(argv=None): "title": args.title, "description": args.description, "priority": args.priority, + "assignee": args.assignee, + "add_labels": args.label, + "remove_labels": args.remove_label, + "due": args.due, + "project": args.project, }.items() if value is not None }, @@ -721,6 +947,21 @@ def main(argv=None): "low": 4, }.get(args.priority), } + team = issue["team"] + if args.assignee: + inp["assigneeId"] = resolve_user(client, args.assignee)["id"] + if args.label: + inp["addedLabelIds"] = [ + resolve_label(client, team, name)["id"] for name in args.label + ] + if args.remove_label: + inp["removedLabelIds"] = [ + resolve_label(client, team, name)["id"] for name in args.remove_label + ] + if args.due: + inp["dueDate"] = parse_date(args.due, "--due") + if args.project: + inp["projectId"] = resolve_project(client, args.project)["id"] inp = {key: value for key, value in inp.items() if value is not None} if not inp: fail("issue update requires at least one field to change") @@ -789,6 +1030,81 @@ def main(argv=None): if args.action == "list": project_list(client, args) return + if args.action == "update": + if all( + value is None + for value in ( + args.name, + args.description, + args.status, + args.start_date, + args.target_date, + args.priority, + ) + ): + fail("project update requires at least one field to change") + if args.description: + validate_project_description(args.description) + require_confirmation(args) + if args.dry_run: + operations = [ + {"operation": "resolve project", "reference": args.project} + ] + if args.status: + operations.append( + { + "operation": "resolve project status", + "reference": args.status, + } + ) + operations.append( + { + "operation": "projectUpdate", + "project": args.project, + "input": { + key: value + for key, value in { + "name": args.name, + "description": args.description, + "status": args.status, + "start_date": args.start_date, + "target_date": args.target_date, + "priority": args.priority, + }.items() + if value is not None + }, + } + ) + preview(args, operations, mutation=True) + return + project = resolve_project(client, args.project) + inp = { + "name": args.name, + "description": args.description, + "priority": { + "none": 0, + "urgent": 1, + "high": 2, + "medium": 3, + "low": 4, + }.get(args.priority), + } + if args.status: + inp["statusId"] = resolve_project_status(client, args.status)["id"] + if args.start_date: + inp["startDate"] = parse_date(args.start_date, "--start-date") + if args.target_date: + inp["targetDate"] = parse_date(args.target_date, "--target-date") + inp = {key: value for key, value in inp.items() if value is not None} + emit( + client.run( + "mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { success project { %s } } }" + % PROJECT_FIELDS, + {"id": project["id"], "input": inp}, + ), + args, + ) + return emit(resolve_project(client, args.project), args) return if args.noun == "cycle": @@ -817,6 +1133,25 @@ def main(argv=None): args, ) return + if args.noun == "state": + if args.dry_run: + preview( + args, + [ + {"operation": "resolve team", "reference": args.team}, + {"operation": "list team states", "team": args.team}, + ], + ) + return + team = resolve_team(client, args.team) + emit( + client.run( + "query TeamStates($id: String!, $first: Int!) { team(id: $id) { states(first: $first) { nodes { id name type description } } } }", + {"id": team["id"], "first": args.limit}, + ), + args, + ) + return if __name__ == "__main__": diff --git a/linear/tests/test_linear.py b/linear/tests/test_linear.py index 9be1ec8..ce97edf 100644 --- a/linear/tests/test_linear.py +++ b/linear/tests/test_linear.py @@ -60,7 +60,7 @@ class LinearCliTests(unittest.TestCase): state_client = StateClient() self.assertEqual( cli.resolve_state( - state_client, {"team": {"id": "team-id", "key": "ENG"}}, "Done" + state_client, {"id": "team-id", "key": "ENG"}, "Done" ), {"id": "state-id", "name": "Done", "type": "completed"}, ) @@ -443,14 +443,14 @@ class LinearCliTests(unittest.TestCase): "update", "move", "comment", + "archive", + "unarchive", ) ), *(["document", action] for action in ("list", "search", "get")), - *( - [noun, action] - for noun in ("project", "cycle") - for action in ("list", "get") - ), + *(["project", action] for action in ("list", "get", "update")), + *(["cycle", action] for action in ("list", "get")), + ["state", "list"], ] for path in paths: with self.subTest(path=path): @@ -630,7 +630,7 @@ class LinearCliTests(unittest.TestCase): with redirect_stderr(stderr): with self.assertRaises(SystemExit): cli.resolve_state( - Client(), {"team": {"id": "team-id", "key": "ENG"}}, "Missing" + Client(), {"id": "team-id", "key": "ENG"}, "Missing" ) self.assertIn("available states: Todo, Done", stderr.getvalue()) @@ -655,6 +655,514 @@ class LinearCliTests(unittest.TestCase): self.assertEqual(output, "") self.assertIn("GraphQL error: test", error) + def test_project_update_requires_a_field_before_network(self): + original = cli.urlopen + try: + cli.urlopen = lambda *_args, **_kwargs: self.fail( + "invalid project update made a network call" + ) + code, output, error = self.run_cli( + ["project", "update", "Roadmap", "--json"] + ) + finally: + cli.urlopen = original + self.assertEqual(code, 2) + self.assertEqual(output, "") + self.assertIn("at least one field", error) + + def test_project_update_resolves_status_dates_and_priority(self): + original = cli.Client.run + calls = [] + + def run(_self, query, variables): + calls.append((query, variables)) + if query.startswith("query ProjectsByName"): + return { + "projects": {"nodes": [{"id": "project-id", "name": "Roadmap"}]} + } + if query.startswith("query ProjectStatuses"): + return { + "projectStatuses": { + "nodes": [ + { + "id": "status-id", + "name": "In Progress", + "type": "started", + } + ] + } + } + return { + "projectUpdate": {"success": True, "project": {"id": "project-id"}} + } + + try: + cli.Client.run = run + code, output, error = self.run_cli( + [ + "project", + "update", + "Roadmap", + "--name", + "Q3 Roadmap", + "--status", + "started", + "--start-date", + "2026-08-10", + "--target-date", + "2026-11-30", + "--priority", + "high", + "--confirm", + "--json", + ] + ) + finally: + cli.Client.run = original + self.assertEqual(code, 0, error) + query, variables = calls[-1] + self.assertIn( + "mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!)", + query, + ) + self.assertIn("projectUpdate(id: $id, input: $input)", query) + self.assertIn("success project", query) + self.assertEqual(variables["id"], "project-id") + self.assertEqual( + variables["input"], + { + "name": "Q3 Roadmap", + "statusId": "status-id", + "startDate": "2026-08-10", + "targetDate": "2026-11-30", + "priority": 2, + }, + ) + + def test_project_description_length_guard(self): + original = cli.urlopen + try: + cli.urlopen = lambda *_args, **_kwargs: self.fail( + "oversized description made a network call" + ) + code, output, error = self.run_cli( + [ + "project", + "update", + "Roadmap", + "--description", + "x" * 256, + "--confirm", + "--json", + ] + ) + finally: + cli.urlopen = original + self.assertEqual(code, 2) + self.assertEqual(output, "") + self.assertIn("255", error) + + def test_project_update_dry_run_includes_intent(self): + code, output, error = self.run_cli( + [ + "project", + "update", + "Roadmap", + "--description", + "Q3 plan", + "--status", + "started", + "--dry-run", + "--json", + ] + ) + self.assertEqual(code, 0, error) + operations = json.loads(output)["operations"] + self.assertIn( + {"operation": "resolve project status", "reference": "started"}, + operations, + ) + self.assertEqual(operations[-1]["operation"], "projectUpdate") + self.assertEqual(operations[-1]["input"]["description"], "Q3 plan") + + def test_issue_archive_and_unarchive_require_confirm(self): + for action in ("archive", "unarchive"): + with self.subTest(action=action): + code, _output, error = self.run_cli( + ["issue", action, "ENG-42", "--json"] + ) + self.assertEqual(code, 6) + self.assertIn("--confirm", error) + + def test_issue_archive_uses_entity_payload(self): + original = cli.Client.run + queries = [] + + def run(_self, query, variables): + queries.append(query) + if query.startswith("query Issue"): + return { + "issue": {"id": "issue-id", "team": {"id": "team-id", "key": "ENG"}} + } + return { + "issueArchive": {"success": True, "entity": {"id": "issue-id"}} + } + + try: + cli.Client.run = run + code, output, error = self.run_cli( + ["issue", "archive", "ENG-42", "--confirm", "--json"] + ) + finally: + cli.Client.run = original + self.assertEqual(code, 0, error) + self.assertIn("mutation IssueArchive($id: String!)", queries[-1]) + self.assertIn("issueArchive(id: $id) { success entity", queries[-1]) + self.assertEqual( + json.loads(output), + {"issueArchive": {"entity": {"id": "issue-id"}, "success": True}}, + ) + + def test_state_list_resolves_team_and_lists_states(self): + original = cli.Client.run + calls = [] + + def run(_self, query, variables): + calls.append((query, variables)) + if query.startswith("query ResolveTeam"): + return { + "teams": { + "nodes": [ + {"id": "team-id", "key": "ENG", "name": "Engineering"} + ] + } + } + return { + "team": { + "states": { + "nodes": [{"id": "s1", "name": "Todo", "type": "backlog"}] + } + } + } + + try: + cli.Client.run = run + code, output, error = self.run_cli( + ["state", "list", "--team", "ENG", "--json"] + ) + finally: + cli.Client.run = original + self.assertEqual(code, 0, error) + self.assertIn( + "query TeamStates($id: String!, $first: Int!)", calls[-1][0] + ) + self.assertEqual(calls[-1][1], {"id": "team-id", "first": 10}) + self.assertEqual( + json.loads(output), + {"team": {"states": {"nodes": [{"id": "s1", "name": "Todo", "type": "backlog"}]}}}, + ) + + def test_state_list_dry_run_previews_without_network(self): + original = cli.urlopen + try: + cli.urlopen = lambda *_args, **_kwargs: self.fail( + "dry-run made a network call" + ) + code, output, error = self.run_cli( + ["state", "list", "--team", "ENG", "--dry-run", "--json"] + ) + finally: + cli.urlopen = original + self.assertEqual(code, 0, error) + self.assertTrue(json.loads(output)["dry_run"]) + + def test_resolve_user_matches_name_and_email_and_rejects_ambiguity(self): + class Client: + def __init__(self, users): + self.users = users + + def run(self, _query, _variables): + return {"users": {"nodes": self.users}} + + users = [ + { + "id": "u1", + "name": "Ada Lovelace", + "displayName": "Ada", + "email": "ada@example.com", + }, + { + "id": "u2", + "name": "Grace Hopper", + "displayName": "Grace", + "email": "grace@example.com", + }, + ] + self.assertEqual(cli.resolve_user(Client(users), "ada@example.com")["id"], "u1") + self.assertEqual(cli.resolve_user(Client(users), "Ada Lovelace")["id"], "u1") + self.assertEqual(cli.resolve_user(Client(users), "grace")["id"], "u2") + with redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as exc: + cli.resolve_user(Client(users), "missing") + self.assertEqual(exc.exception.code, 2) + + def test_resolve_label_reports_available_names(self): + class Client: + def run(self, _query, _variables): + return { + "team": { + "labels": { + "nodes": [ + {"id": "l1", "name": "bug"}, + {"id": "l2", "name": "feature"}, + ] + } + } + } + + self.assertEqual( + cli.resolve_label(Client(), {"id": "team-id", "key": "ENG"}, "Bug")["id"], + "l1", + ) + stderr = io.StringIO() + with redirect_stderr(stderr): + with self.assertRaises(SystemExit): + cli.resolve_label(Client(), {"id": "team-id", "key": "ENG"}, "Missing") + self.assertIn("available labels: bug, feature", stderr.getvalue()) + + def test_resolve_project_status_matches_name_or_type(self): + class Client: + def run(self, _query, _variables): + return { + "projectStatuses": { + "nodes": [ + {"id": "ps1", "name": "Planned", "type": "planned"}, + {"id": "ps2", "name": "In Progress", "type": "started"}, + ] + } + } + + self.assertEqual(cli.resolve_project_status(Client(), "Planned")["id"], "ps1") + self.assertEqual(cli.resolve_project_status(Client(), "started")["id"], "ps2") + with redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + cli.resolve_project_status(Client(), "nope") + + def test_parse_date_rejects_bad_format(self): + self.assertEqual(cli.parse_date("2026-08-31", "--due"), "2026-08-31") + with redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as exc: + cli.parse_date("08/31/2026", "--due") + self.assertEqual(exc.exception.code, 2) + + def test_issue_create_resolves_project_parent_assignee_labels_state_and_due(self): + original = cli.Client.run + calls = [] + + def run(_self, query, variables): + calls.append((query, variables)) + if query.startswith("query ResolveTeam"): + return { + "teams": { + "nodes": [ + {"id": "team-id", "key": "ENG", "name": "Engineering"} + ] + } + } + if query.startswith("query ProjectsByName"): + return { + "projects": {"nodes": [{"id": "project-id", "name": "Platform"}]} + } + if query.startswith("query Issue("): + return {"issue": {"id": "parent-id"}} + if query.startswith("query Users"): + return { + "users": { + "nodes": [ + { + "id": "user-id", + "name": "Ada Lovelace", + "displayName": "Ada", + "email": "ada@example.com", + } + ] + } + } + if query.startswith("query TeamLabels"): + return { + "team": {"labels": {"nodes": [{"id": "label-id", "name": "bug"}]}} + } + if query.startswith("query TeamStates"): + return { + "team": { + "states": { + "nodes": [ + { + "id": "state-id", + "name": "In Progress", + "type": "started", + } + ] + } + } + } + return {"issueCreate": {"success": True, "issue": {"id": "issue-id"}}} + + try: + cli.Client.run = run + code, output, error = self.run_cli( + [ + "issue", + "create", + "--team", + "ENG", + "--title", + "Ship it", + "--project", + "Platform", + "--parent", + "ENG-41", + "--assignee", + "Ada Lovelace", + "--label", + "bug", + "--state", + "In Progress", + "--due", + "2026-08-31", + "--confirm", + "--json", + ] + ) + finally: + cli.Client.run = original + self.assertEqual(code, 0, error) + query, variables = calls[-1] + self.assertIn("mutation IssueCreate($input: IssueCreateInput!)", query) + self.assertEqual( + variables["input"], + { + "teamId": "team-id", + "title": "Ship it", + "projectId": "project-id", + "parentId": "parent-id", + "assigneeId": "user-id", + "labelIds": ["label-id"], + "stateId": "state-id", + "dueDate": "2026-08-31", + }, + ) + + def test_issue_update_resolves_assignee_labels_due_and_project(self): + original = cli.Client.run + calls = [] + + def run(_self, query, variables): + calls.append((query, variables)) + if query.startswith("query Issue"): + return { + "issue": {"id": "issue-id", "team": {"id": "team-id", "key": "ENG"}} + } + if query.startswith("query Users"): + return { + "users": { + "nodes": [ + { + "id": "user-id", + "name": "Ada Lovelace", + "displayName": "Ada", + "email": "ada@example.com", + } + ] + } + } + if query.startswith("query TeamLabels"): + return { + "team": { + "labels": { + "nodes": [ + {"id": "label-id", "name": "bug"}, + {"id": "label2-id", "name": "priority"}, + ] + } + } + } + if query.startswith("query ProjectsByName"): + return { + "projects": {"nodes": [{"id": "project-id", "name": "Platform"}]} + } + return {"issueUpdate": {"success": True, "issue": {"id": "issue-id"}}} + + try: + cli.Client.run = run + code, output, error = self.run_cli( + [ + "issue", + "update", + "ENG-42", + "--assignee", + "Ada Lovelace", + "--label", + "bug", + "--remove-label", + "priority", + "--due", + "2026-09-15", + "--project", + "Platform", + "--confirm", + "--json", + ] + ) + finally: + cli.Client.run = original + self.assertEqual(code, 0, error) + query, variables = calls[-1] + self.assertIn( + "mutation IssueUpdate($id: String!, $input: IssueUpdateInput!)", query + ) + self.assertEqual(variables["id"], "issue-id") + self.assertEqual( + variables["input"], + { + "assigneeId": "user-id", + "addedLabelIds": ["label-id"], + "removedLabelIds": ["label2-id"], + "dueDate": "2026-09-15", + "projectId": "project-id", + }, + ) + + def test_issue_create_dry_run_lists_resolution_intent(self): + code, output, error = self.run_cli( + [ + "issue", + "create", + "--team", + "ENG", + "--title", + "Ship it", + "--project", + "Platform", + "--assignee", + "Ada", + "--label", + "bug", + "--dry-run", + "--json", + ] + ) + self.assertEqual(code, 0, error) + operations = json.loads(output)["operations"] + self.assertIn( + {"operation": "resolve project", "reference": "Platform"}, operations + ) + self.assertIn( + {"operation": "resolve assignee", "reference": "Ada"}, operations + ) + self.assertEqual(operations[-1]["operation"], "issueCreate") + self.assertEqual(operations[-1]["input"]["labels"], ["bug"]) + if __name__ == "__main__": unittest.main()