feat(linear): project mutations and richer issue verbs in CLI (#288)

## 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>
This commit is contained in:
Magnus Hedemark
2026-08-05 16:43:36 -04:00
committed by GitHub
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 5d101007ef
commit b8a5092c26
9 changed files with 950 additions and 44 deletions
+4 -3
View File
@@ -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 |
+19 -7
View File
@@ -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.
+12
View File
@@ -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"
]
}
]
}
+21 -5
View File
@@ -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
+11 -4
View File
@@ -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
@@ -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.
+6
View File
@@ -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.
+346 -11
View File
@@ -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,10 +787,30 @@ def main(argv=None):
require_confirmation(args)
if args.action == "create":
if args.dry_run:
preview(
args,
[
{"operation": "resolve team", "reference": args.team},
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": {
@@ -638,11 +818,16 @@ def main(argv=None):
"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__":
+515 -7
View File
@@ -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()