Add focused Linear Agent Skill and CLI

Adds a dependency-free task-oriented Linear GraphQL CLI, progressive-disclosure guidance, offline safety tests, public-schema validation, and SkillOpt-derived help and promotion-gate improvements.
This commit is contained in:
Magnus Hedemark
2026-07-17 13:07:24 -04:00
committed by GitHub
parent 089685cef2
commit 672f6190b0
10 changed files with 1749 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
# Linear: focused issue and document operations from the terminal
## 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.
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
project-management work easier to audit.
## What You Get
| Path | Provides |
|---|---|
| `scripts/linear` | Dependency-free Python CLI for Linear GraphQL reads and issue mutations |
| `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 |
## Quick Start
Set one credential for live API calls. Use a placeholder, never a real token in documentation or shell history.
```bash
export LINEAR_API_KEY='your-linear-personal-api-key'
# Or: export LINEAR_ACCESS_TOKEN='your-oauth-access-token'
linear/scripts/linear team list --limit 20 --json
linear/scripts/linear issue search "customer import" --json
```
Preview a write before confirming it:
```bash
linear/scripts/linear issue create --team ENG --title "Review import errors" --dry-run --json
```
## Triggers
- List, search, inspect, create, update, move, or comment on Linear issues
- Find Linear teams, projects, cycles, or documents
- Run a small, explicit Linear GraphQL query from a terminal
## Requirements
- Python 3.8 or later; no third-party packages
- Network access for live API calls
- `LINEAR_API_KEY` or `LINEAR_ACCESS_TOKEN` for live API calls
- A Linear workspace and credentials with permission for the requested action
+110
View File
@@ -0,0 +1,110 @@
---
name: linear
description: >-
Manage Linear teams, projects, cycles, issues, comments, workflow state, and documents from a terminal through
Linear's public GraphQL API. Use when a user asks to list, search, inspect, create, update,
move, or comment on Linear work, or to find Linear documents. Do not use to embed a live agent
inside Linear or to build an MCP integration.
license: MIT
compatibility: Requires Python 3.8+; network access and a Linear personal API key or OAuth access token for live API requests.
metadata:
service: Linear
api: GraphQL
graphql-docs: https://linear.app/developers/graphql
oauth-docs: https://linear.app/developers/oauth-2-0-authentication
rate-limit-docs: https://linear.app/developers/rate-limiting
agent-interaction-docs: https://linear.app/developers/agent-interaction
allowed-tools: Bash Read
---
# Linear
Use `scripts/linear` from this skill directory. It is a small, dependency-free wrapper around
Linear's public GraphQL API, not an MCP server. Use command-specific `--help` rather than copying
the full command reference into a response.
## Setup
1. Inspect the available command and relevant noun: `scripts/linear --help` and `scripts/linear issue --help`.
2. For a live request, set exactly one credential in the process environment. Use `LINEAR_API_KEY`
for a personal key or `LINEAR_ACCESS_TOKEN` for OAuth. Never print, persist, or place either in a command transcript.
3. Begin with bounded discovery. Reads default to `--limit 10`; the maximum is 100.
4. Output is always JSON: compact with `--json`, indented without it. `--dry-run` makes no network request and previews the operation.
## Command Map
| Need | Command |
|---|---|
| Confirm current identity | `scripts/linear whoami --json` |
| Discover teams | `scripts/linear team list --limit 20 --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` |
| List or read projects | `scripts/linear project list --team ENG --json` or `scripts/linear project get "Roadmap" --json` |
| 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` |
| Use a documented unsupported GraphQL operation | `scripts/linear raw 'query { viewer { id } }' --json` |
## Choose the Smallest Read
| Situation | Use |
|---|---|
| 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` |
| 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
the official GraphQL documentation to confirm field names and permissions.
## State Changes
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:
1. Identify the issue/team/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.
## 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.
- 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.
## References
| When you need... | Load... |
|---|---|
| Linear's data model, workflow semantics, safe mutation recipes | `references/domain-and-workflows.md` |
| GraphQL endpoint, auth, filters, pagination, errors, rate limits | `references/graphql-contract.md` |
| CLI vs MCP vs raw GraphQL vs Agent Session decision | `references/integration-boundaries.md` |
| Source URLs, access dates, schema verification procedure | `references/sources.md` |
## Verification
Run the offline tests and repository validator after changes:
```bash
python3 -m unittest linear/tests/test_linear.py
ruby scripts/validate-skills.rb
```
## When Not To Use
Use Linear's native MCP or agent-session/webhook API when the task is to embed a live agent inside
Linear rather than operate Linear from a terminal.
+48
View File
@@ -0,0 +1,48 @@
# Linear Domain And Workflows
Linear organizes work around teams. Issues belong to a team and can be associated with a project
and cycle. A project expresses a larger outcome; a cycle groups time-bounded team work. Use an
issue for actionable work and a document for durable narrative, planning, or reference material.
## Workflow Semantics
Workflow states have types including `triage`, `backlog`, `unstarted`, `started`, `completed`, and
`canceled`. Creating an issue without `stateId` places it in the team's first Backlog state, or in
Triage when that feature is enabled. Priorities are numeric in the API: 0 none, 1 urgent, 2 high,
3 medium, and 4 low.
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.
## Choosing A Work Item
Start with an existing issue whenever a request refers to known work. Search by distinctive words,
then inspect the result before changing it. Use the issue identifier in follow-up work because it
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.
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
`+++ Title` to open and `+++` to close.
## Safe Mutation Recipe
1. Read the target issue and relevant team, project, cycle, or state first.
2. Check for an existing issue with `issue search` before creating another one.
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.
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
covers; link or update the existing issue instead.
Do not treat a completed or canceled state as reversible without checking the team's workflow and
the requested recovery path. State names are workspace-defined, so resolve the exact destination
inside the issue's own team rather than assuming a universal “Done” state.
+38
View File
@@ -0,0 +1,38 @@
# GraphQL Contract
Endpoint: `https://api.linear.app/graphql`. Send JSON GraphQL requests with `Content-Type:
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.
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
does not initiate OAuth, store credentials, refresh tokens, or print either supported credential.
Filter input supports equality, inequality, collection membership, comparisons for number/date
fields, and string operators such as `contains` and `startsWith`. Relationship filters can narrow
results by related team, state, assignee, project, or labels. Prefer these filters to client-side
filtering and ask for only fields needed for the task.
All connections use Relay cursor pagination: request `first` and then pass `pageInfo.endCursor` as
`after` while `hasNextPage` is true. The CLI intentionally does not automatically paginate; keep
reads bounded with `--limit`. Use server filters rather than downloading a workspace and filtering
locally.
Check GraphQL's `errors` array even on HTTP 200 because data can be partial. Rate limits and query
complexity are reported in response headers. As accessed on 2026-07-17, the official rate-limit
page contains a conflicting API-key request limit (5,000 in prose and 2,500 in its table); inspect
current headers and documentation rather than hard-coding either value. It also documents API-key
complexity at 3,000,000 points per hour and a 10,000-point maximum for one query.
Linear does not version this GraphQL API. Inspect schema deprecations and the `[API]` changelog
before relying on a field. When a query fails after a schema change, re-run public introspection in
Apollo Studio, update the narrow field selection, and add an offline contract test before release.
Archived records are excluded from paginated responses by default and can be included with
`includeArchived: true` when the connection supports it. Do not add polling loops for updates:
Linear recommends webhooks for applications that need near-real-time changes.
@@ -0,0 +1,38 @@
# Integration Boundaries
| Need | Use |
|---|---|
| A bounded terminal read or carefully confirmed issue mutation | This CLI |
| An interactive tool connection inside an AI client | Linear's native MCP integration |
| A documented operation absent from the focused command surface | `linear raw` with a narrow GraphQL query |
| A multi-user application acting for each user | OAuth 2.0 with user access tokens |
| A workspace agent or service actor | OAuth actor authorization or client credentials, as documented by Linear |
| An agent that receives delegation, mentions, or user follow-ups in Linear | Agent Session and webhook APIs |
This CLI intentionally does not run an OAuth callback server, manage refresh tokens, receive
webhooks, create Agent Sessions, or emit Agent Activities. Those paths require an application
integration with secure token storage, webhook verification, and lifecycle handling.
Agent Session webhooks notify a configured agent when it is mentioned, delegated an issue, or
receives a follow-up prompt. Their receiver must respond within five seconds, and a new session
should send an activity or external URL within ten seconds. These availability requirements do not
fit a one-shot terminal command.
Agent Activities are semantic progress events such as thoughts, actions, elicitation requests,
responses, and errors. They belong to an Agent Session and are validated by Linear. Do not use
ordinary issue comments as a substitute when building an embedded Linear agent integration.
OAuth application integrations should request the smallest documented scope. The documented
`admin` scope is not a default; use it only when the integration truly needs administrative API
access. This terminal CLI receives an already-issued environment credential and never chooses
scopes itself.
For a standalone agent operating Linear from a terminal, prefer this CLI's read commands and its
`--dry-run` plus `--confirm` mutation gate. For an unsupported but documented GraphQL operation,
use `raw` only after confirming the exact field, permissions, target, scope, and rollback path.
Use the GraphQL schema explorer before promoting a repeated raw operation into this CLI. A command
belongs in the focused surface only when it has a recurring agent workflow, a clear safe default,
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`.
+40
View File
@@ -0,0 +1,40 @@
# Sources
Accessed 2026-07-17. These official Linear pages are the only external sources used by this skill.
| Source | Establishes |
|---|---|
| https://linear.app/developers/graphql | Endpoint, authentication headers, GraphQL errors, issue identifiers, issue mutations, documents |
| https://linear.app/developers/pagination | Relay cursor pagination and bounded `first` requests |
| https://linear.app/developers/filtering | Server-side filters and relationship filters |
| https://linear.app/developers/rate-limiting | Request and complexity limits, headers, rate-limit errors |
| https://linear.app/developers/deprecations | No API versions, schema deprecation policy, changelog notices |
| https://linear.app/developers/oauth-2-0-authentication | OAuth tokens, scopes, refresh flow, and app actors |
| https://linear.app/developers/agent-interaction | Agent Sessions, activities, and session webhooks |
| https://linear.app/developers/agent-best-practices | Markdown and interaction guidance for agent workflows |
## Schema Re-verification
Linear exposes its public GraphQL schema through Apollo Studio without login. Open the API schema
from the GraphQL documentation, inspect the relevant query, mutation, input, and object fields,
then test the narrow query in the Explorer. Record the exact selection and add or update an offline
CLI contract test. Do not infer field shapes from names or from an older SDK.
For authentication behavior, verify the request header against the GraphQL and OAuth pages. For
pagination or filters, verify both the connection arguments and the relevant filter input type.
For mutation behavior, verify the mutation input and returned payload fields before implementation.
When official documentation and a live schema differ, treat the live public schema as the contract
for field availability and retain the documentation URL plus access date as provenance. If an
official page contains internally conflicting limit values, avoid encoding either value in CLI
behavior and inspect response headers during credentialed operation.
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.
## Document URLs
The CLI extracts a trailing 12-character slug ID from Linear document URLs as an observed current
Linear URL convention, not an officially documented contract. If URL extraction fails, supply the
document UUID or slug ID instead.
+772
View File
@@ -0,0 +1,772 @@
#!/usr/bin/env python3
"""Small, dependency-free CLI for Linear's public GraphQL API."""
import argparse
import json
import os
import re
import sys
from urllib.error import HTTPError, URLError
from urllib.parse import unquote, urlparse
from urllib.request import Request, urlopen
ENDPOINT = "https://api.linear.app/graphql"
DEFAULT_LIMIT = 10
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
)
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 = (
ISSUE_FIELDS
+ " project { id name url } cycle { id number name startsAt endsAt } parent { id identifier title } children(first: 50) { nodes { id identifier title state { id name type } } } comments(first: 50) { nodes { id body createdAt user { id name } } } relations(first: 50) { nodes { id type relatedIssue { id identifier title } } } dueDate attachments { nodes { id url title } }"
)
DOCUMENT_FIELDS = "id title slugId url createdAt updatedAt content"
PROJECT_FIELDS = "id name description status { id name type } progress teams(first: 10) { nodes { id name key } } url"
CYCLE_FIELDS = (
"id number name description startsAt endsAt progress team { id name key }"
)
PREVIEW_NOTE = "Previews intent before live resolution. Friendly names (team key, state name) are resolved only during the real run. Use read-only discovery to verify resolution."
GLOBAL_OPTIONS_HELP = (
"Global options (accepted anywhere): --json; --dry-run; "
"--limit LIMIT (1-100; default: 10)."
)
def fail(message, code=2):
print("Error: " + message, file=sys.stderr)
raise SystemExit(code)
def require_found(value, kind, ref):
if value is None:
fail("%s %r was not found" % (kind, ref))
return value
def parse_json(value, label):
try:
result = json.loads(value)
except json.JSONDecodeError as exc:
fail("%s must be valid JSON: %s" % (label, exc))
if not isinstance(result, dict):
fail("%s must be a JSON object" % label)
return result
def parse_document_ref(ref):
"""Return ('id', uuid) or ('slugId', slug) without treating a slug as an ID."""
if UUID_RE.match(ref):
return "id", ref
parsed = urlparse(ref)
if parsed.scheme and parsed.netloc:
if parsed.netloc.lower() not in ("linear.app", "www.linear.app"):
fail("document URL must be hosted by linear.app")
parts = [unquote(part) for part in parsed.path.split("/") if part]
try:
index = parts.index("document")
document_slug = parts[index + 1]
except (ValueError, IndexError):
fail("document URL must contain /document/SLUG")
match = re.search(r"-([0-9a-f]{12})$", document_slug, re.I)
if not match:
fail("document URL has no slug ID")
return "slugId", match.group(1)
if not ref or "/" in ref:
fail("document reference must be a UUID, slug ID, or Linear document URL")
return "slugId", ref
def is_mutation(query):
stripped = re.sub(r"#[^\n]*", "", query)
return bool(re.search(r"\bmutation\b", stripped, re.I))
class Client:
def __init__(self, args):
self.args = args
def authorization(self):
key = os.getenv("LINEAR_API_KEY")
token = os.getenv("LINEAR_ACCESS_TOKEN")
if key and token:
fail("set exactly one of LINEAR_API_KEY or LINEAR_ACCESS_TOKEN", 3)
if key:
return key
if token:
return "Bearer " + token
return None
def run(self, query, variables=None):
if self.args.dry_run:
return {
"dry_run": True,
"endpoint": ENDPOINT,
"query": query,
"variables": variables or {},
}
auth = self.authorization()
if not auth:
fail(
"LINEAR_API_KEY or LINEAR_ACCESS_TOKEN is required unless using --dry-run",
3,
)
payload = json.dumps({"query": query, "variables": variables or {}}).encode(
"utf-8"
)
request = Request(
ENDPOINT,
payload,
{
"Authorization": auth,
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8")
except HTTPError as exc:
body = exc.read().decode("utf-8", "replace")
try:
data = json.loads(body)
except json.JSONDecodeError:
fail("HTTP %s: %s" % (exc.code, body[:300]), 4)
return self.handle_response(data)
except URLError as exc:
fail("network error: %s" % exc.reason, 4)
try:
data = json.loads(body)
except json.JSONDecodeError:
fail("Linear returned invalid JSON", 4)
return self.handle_response(data)
def handle_response(self, data):
if data.get("errors"):
first = data["errors"][0]
message = first.get("message", "unknown GraphQL error")
print("GraphQL error: " + message, file=sys.stderr)
raise SystemExit(5)
return data.get("data", data)
def emit(value, args):
if args.json or args.dry_run:
print(json.dumps(value, sort_keys=True))
return
print(json.dumps(value, indent=2, sort_keys=True))
def validate_limit(args):
if not 1 <= args.limit <= MAX_LIMIT:
fail("--limit must be between 1 and %d" % MAX_LIMIT)
def require_confirmation(args):
if not args.dry_run and not args.confirm:
fail(
"a real mutation requires --confirm after confirming target, scope, and rollback path",
6,
)
def nodes(data, key):
connection = data.get(key) or {}
return connection.get("nodes", []) if isinstance(connection, dict) else connection
def resolve_team(client, team):
query = "query ResolveTeam($first: Int!) { teams(first: $first) { nodes { id name key } } }"
data = client.run(query, {"first": MAX_LIMIT})
matches = [
item
for item in nodes(data, "teams")
if item["id"] == team
or item["key"].lower() == team.lower()
or item["name"].lower() == team.lower()
]
if len(matches) != 1:
fail(
"team %r did not resolve to exactly one team; use its exact key or name"
% team
)
return matches[0]
def resolve_issue(client, ref):
query = "query Issue($id: String!) { issue(id: $id) { %s } }" % ISSUE_FIELDS
data = client.run(query, {"id": ref})
return require_found(data.get("issue"), "issue", ref)
def resolve_state(client, issue, 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})
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()]
if len(matches) != 1:
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)
)
return matches[0]
def preview(args, operations, mutation=False):
value = {"dry_run": True, "operations": operations}
if mutation:
value["preview_note"] = PREVIEW_NOTE
emit(value, args)
def issue_list(client, args):
filters = []
variables = {"first": args.limit}
for name, field in (
("team", "team"),
("state", "state"),
("assignee", "assignee"),
("label", "labels"),
):
value = getattr(args, name)
if value:
if name == "team":
filters.append("team: { key: { eq: $team } }")
elif name == "label":
filters.append("%s: { some: { name: { eq: $%s } } }" % (field, name))
else:
filters.append("%s: { name: { eq: $%s } }" % (field, name))
variables[name] = value
query = (
"query Issues($first: Int!%s) { issues(first: $first%s) { nodes { %s } } }"
% (
"".join(", $%s: String!" % name for name in variables if name != "first"),
", filter: { %s }" % ", ".join(filters) if filters else "",
ISSUE_FIELDS,
)
)
emit(client.run(query, variables), args)
def document_query(ref):
kind, value = parse_document_ref(ref)
if kind == "id":
return (
"query Document($ref: String!) { document(id: $ref) { %s } }"
% DOCUMENT_FIELDS,
{"ref": value},
)
return (
"query DocumentBySlugId($ref: String!) { documents(filter: { slugId: { eq: $ref } }, first: 1) { nodes { %s } } }"
% DOCUMENT_FIELDS,
{"ref": value},
)
def project_list(client, args):
variables = {"first": args.limit}
filter_clause = ""
if args.team:
variables["team"] = args.team
filter_clause = (
", filter: { accessibleTeams: { some: { key: { eq: $team } } } }"
)
query = (
"query Projects($first: Int!%s) { projects(first: $first%s) { nodes { %s } } }"
% (", $team: String!" if args.team else "", filter_clause, PROJECT_FIELDS)
)
emit(client.run(query, variables), args)
def resolve_project(client, ref):
if UUID_RE.match(ref):
query = (
"query Project($id: String!) { project(id: $id) { %s } }" % PROJECT_FIELDS
)
project = client.run(query, {"id": ref}).get("project")
return require_found(project, "project", ref)
query = (
"query ProjectsByName($name: String!) { projects(first: 2, filter: { name: { eq: $name } }) { nodes { %s } } }"
% PROJECT_FIELDS
)
matches = nodes(client.run(query, {"name": ref}), "projects")
if len(matches) != 1:
fail(
"project %r did not resolve to exactly one project; use its UUID or exact name"
% ref
)
return matches[0]
class LinearArgumentParser(argparse.ArgumentParser):
def __init__(self, *args, **kwargs):
kwargs.setdefault("epilog", GLOBAL_OPTIONS_HELP)
super().__init__(*args, **kwargs)
def build_parser():
parser = LinearArgumentParser(
prog="linear", description="Task-oriented Linear GraphQL CLI."
)
parser.add_argument("--json", action="store_true", help="Emit JSON to stdout.")
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview GraphQL operations without network access.",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help="Page size for reads (1-100; default: 10).",
)
root = parser.add_subparsers(dest="noun", required=True)
root.add_parser(
"whoami",
help="Show the authenticated Linear viewer.",
description="Example: linear whoami --json",
)
root.add_parser(
"raw",
help='Run an explicit GraphQL escape hatch. Example: linear raw "query { viewer { id } }".',
description='Example: linear raw "query { viewer { id } }"',
)
raw = root.choices["raw"]
raw.add_argument("query")
raw.add_argument(
"--variables", default="{}", help="JSON object of GraphQL variables."
)
raw.add_argument(
"--confirm", action="store_true", help="Required for a real raw mutation."
)
team = root.add_parser("team", help="Read teams.")
team.add_subparsers(dest="action", required=True).add_parser(
"list",
help="List teams.",
description="Example: linear team list --limit 20 --json",
)
issue = root.add_parser("issue", help="Read and manage issues.")
actions = issue.add_subparsers(dest="action", required=True)
p = actions.add_parser(
"list",
help="List issues. Example: linear issue list --team ENG --state Started --limit 20.",
description="Example: linear issue list --team ENG --state Started --limit 20",
)
p.add_argument("--team", metavar="TEAM_KEY", help="Exact team key.")
p.add_argument("--state")
p.add_argument("--assignee")
p.add_argument("--label")
p = actions.add_parser(
"search",
help='Search issues. Example: linear issue search "customer import".',
description='Example: linear issue search "customer import"',
)
p.add_argument("query")
p = actions.add_parser(
"get",
help="Get an issue by UUID or identifier. Example: linear issue get ENG-42 or linear issue get <uuid>.",
description="Example: linear issue get ENG-42 or linear issue get <uuid>.",
)
p.add_argument("issue")
p.add_argument(
"--detail",
action="store_true",
help="Include project, cycle, hierarchy, comments, relations, and attachments.",
)
p = actions.add_parser(
"create",
help='Create an issue. Example: linear issue create --team ENG --title "Fix login" --priority high.',
description='Example: linear issue create --team ENG --title "Fix login" --priority high',
)
p.add_argument(
"--team", required=True, metavar="TEAM", help="Team key or exact name."
)
p.add_argument("--title", required=True)
p.add_argument("--description")
p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low"))
p.add_argument("--confirm", action="store_true")
p = actions.add_parser(
"update",
help="Update an issue.",
description='Example: linear issue update ENG-42 --title "Updated title"',
)
p.add_argument("issue")
p.add_argument("--title")
p.add_argument("--description")
p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low"))
p.add_argument("--confirm", action="store_true")
p = actions.add_parser(
"move",
help='Move an issue to a workflow state. Example: linear issue move ENG-42 --state "In Progress".',
description='Example: linear issue move ENG-42 --state "In Progress"',
)
p.add_argument("issue")
p.add_argument("--state", required=True)
p.add_argument("--confirm", action="store_true")
p = actions.add_parser(
"comment",
help="Add an issue comment.",
description='Example: linear issue comment ENG-42 --body "Ready for review"',
)
p.add_argument("issue")
p.add_argument("--body", required=True)
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(
"list",
help="List documents.",
description="Example: linear document list --limit 20 --json",
)
p = actions.add_parser(
"search",
help="Search documents.",
description="Example: linear document search roadmap",
)
p.add_argument("query")
p = actions.add_parser(
"get",
help="Get a document by UUID, slug ID, or URL. Example: linear document get <uuid>, linear document get <slug>, or linear document get https://linear.app/acme/document/doc-slug.",
description="Example: linear document get roadmap-q3",
)
p.add_argument("ref")
project = root.add_parser("project", help="Read Linear projects.")
actions = project.add_subparsers(dest="action", required=True)
p = actions.add_parser(
"list",
help="List projects.",
description="Example: linear project list --team ENG --json",
)
p.add_argument("--team", metavar="TEAM_KEY", help="Exact team key.")
p = actions.add_parser(
"get",
help="Get a project by UUID or exact name.",
description='Example: linear project get "Roadmap"',
)
p.add_argument("project")
cycle = root.add_parser("cycle", help="Read Linear cycles.")
actions = cycle.add_subparsers(dest="action", required=True)
p = actions.add_parser(
"list",
help="List cycles.",
description="Example: linear cycle list --team ENG --json",
)
p.add_argument("--team", metavar="TEAM_KEY", help="Exact team key.")
p = actions.add_parser(
"get",
help="Get a cycle by UUID.",
description="Example: linear cycle get 123e4567-e89b-12d3-a456-426614174000",
)
p.add_argument("cycle")
return parser
def extract_globals(argv):
values, retained, index = [], [], 0
flags = {"--json": 0, "--dry-run": 0, "--limit": 1}
while index < len(argv):
arg = argv[index]
if arg in flags:
values.append(arg)
if flags[arg]:
if index + 1 == len(argv):
fail(arg + " requires a value")
values.append(argv[index + 1])
index += 1
else:
retained.append(arg)
index += 1
return values + retained
def main(argv=None):
parser = build_parser()
args = parser.parse_args(
extract_globals(list(argv if argv is not None else sys.argv[1:]))
)
validate_limit(args)
if (
args.noun == "issue"
and args.action == "update"
and all(
value is None for value in (args.title, args.description, args.priority)
)
):
fail("issue update requires at least one field to change")
client = Client(args)
if args.noun == "whoami":
emit(client.run("query Viewer { viewer { id name email displayName } }"), args)
return
if args.noun == "raw":
variables = parse_json(args.variables, "--variables")
mutation = is_mutation(args.query)
if mutation:
require_confirmation(args)
if mutation and args.dry_run:
preview(
args,
[
{
"operation": "raw mutation",
"query": args.query,
"variables": variables,
}
],
mutation=True,
)
return
emit(client.run(args.query, variables), args)
return
if args.noun == "team":
emit(
client.run(
"query Teams($first: Int!) { teams(first: $first) { nodes { id name key description } } }",
{"first": args.limit},
),
args,
)
return
if args.noun == "issue":
if args.action == "list":
issue_list(client, args)
return
if args.action == "search":
emit(
client.run(
"query SearchIssues($term: String!, $first: Int!) { searchIssues(term: $term, first: $first) { nodes { %s } } }"
% ISSUE_FIELDS,
{"term": args.query, "first": args.limit},
),
args,
)
return
if args.action == "get":
if args.dry_run:
preview(
args,
[
{
"operation": "resolve issue",
"reference": args.issue,
"detail": args.detail,
}
],
)
return
if args.detail:
emit(
require_found(
client.run(
"query Issue($id: String!) { issue(id: $id) { %s } }"
% ISSUE_DETAIL_FIELDS,
{"id": args.issue},
).get("issue"),
"issue",
args.issue,
),
args,
)
return
emit(resolve_issue(client, args.issue), args)
return
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,
},
},
],
mutation=True,
)
return
team = resolve_team(client, args.team)
inp = {
"teamId": team["id"],
"title": args.title,
"description": args.description,
"priority": {
"none": 0,
"urgent": 1,
"high": 2,
"medium": 3,
"low": 4,
}.get(args.priority),
}
emit(
client.run(
"mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { %s } } }"
% ISSUE_FIELDS,
{
"input": {
key: value
for key, value in inp.items()
if value is not None
}
},
),
args,
)
return
if args.dry_run:
operations = [{"operation": "resolve issue", "reference": args.issue}]
if args.action == "move":
operations.append(
{
"operation": "issueMove",
"issue": args.issue,
"target_state": args.state,
}
)
elif args.action == "update":
operations.append(
{
"operation": "issueUpdate",
"issue": args.issue,
"input": {
key: value
for key, value in {
"title": args.title,
"description": args.description,
"priority": args.priority,
}.items()
if value is not None
},
}
)
else:
operations.append(
{
"operation": "commentCreate",
"issue": args.issue,
"body": args.body,
}
)
preview(args, operations, mutation=True)
return
issue = resolve_issue(client, args.issue)
if args.action == "update":
inp = {
"title": args.title,
"description": args.description,
"priority": {
"none": 0,
"urgent": 1,
"high": 2,
"medium": 3,
"low": 4,
}.get(args.priority),
}
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")
emit(
client.run(
"mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { %s } } }"
% ISSUE_FIELDS,
{"id": issue["id"], "input": inp},
),
args,
)
return
if args.action == "move":
state = resolve_state(client, issue, args.state)
emit(
client.run(
"mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { %s } } }"
% ISSUE_FIELDS,
{"id": issue["id"], "input": {"stateId": state["id"]}},
),
args,
)
return
emit(
client.run(
"mutation Comment($input: CommentCreateInput!) { commentCreate(input: $input) { success comment { id body createdAt } } }",
{"input": {"issueId": issue["id"], "body": args.body}},
),
args,
)
return
if args.noun == "document":
if args.action == "list":
emit(
client.run(
"query Documents($first: Int!) { documents(first: $first) { nodes { id title slugId url updatedAt } } }",
{"first": args.limit},
),
args,
)
return
if args.action == "search":
emit(
client.run(
"query SearchDocuments($term: String!, $first: Int!) { searchDocuments(term: $term, first: $first) { nodes { id title slugId url updatedAt } } }",
{"term": args.query, "first": args.limit},
),
args,
)
return
kind, _value = parse_document_ref(args.ref)
query, variables = document_query(args.ref)
data = client.run(query, variables)
emit(
require_found(
data.get("document")
if kind == "id"
else (nodes(data, "documents") or [None])[0],
"document",
args.ref,
),
args,
)
return
if args.noun == "project":
if args.action == "list":
project_list(client, args)
return
emit(resolve_project(client, args.project), args)
return
if args.noun == "cycle":
if args.action == "list":
variables = {"first": args.limit}
filter_clause = ""
if args.team:
variables["team"] = args.team
filter_clause = ", filter: { team: { key: { eq: $team } } }"
query = (
"query Cycles($first: Int!%s) { cycles(first: $first%s) { nodes { %s } } }"
% (", $team: String!" if args.team else "", filter_clause, CYCLE_FIELDS)
)
emit(client.run(query, variables), args)
return
emit(
require_found(
client.run(
"query Cycle($id: String!) { cycle(id: $id) { %s } }"
% CYCLE_FIELDS,
{"id": args.cycle},
).get("cycle"),
"cycle",
args.cycle,
),
args,
)
return
if __name__ == "__main__":
main()
+647
View File
@@ -0,0 +1,647 @@
import importlib.machinery
import io
import json
import os
import pathlib
import unittest
from contextlib import redirect_stderr, redirect_stdout
SCRIPT = pathlib.Path(__file__).parents[1] / "scripts" / "linear"
cli = importlib.machinery.SourceFileLoader("linear_cli", str(SCRIPT)).load_module()
class LinearCliTests(unittest.TestCase):
def run_cli(self, arguments):
stdout, stderr = io.StringIO(), io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
try:
cli.main(arguments)
except SystemExit as exc:
return exc.code, stdout.getvalue(), stderr.getvalue()
return 0, stdout.getvalue(), stderr.getvalue()
def test_document_reference_parsing(self):
uuid = "123e4567-e89b-12d3-a456-426614174000"
self.assertEqual(cli.parse_document_ref(uuid), ("id", uuid))
self.assertEqual(cli.parse_document_ref("roadmap-q3"), ("slugId", "roadmap-q3"))
self.assertEqual(
cli.parse_document_ref(
"https://linear.app/acme/document/my-roadmap-38359beef67c"
),
("slugId", "38359beef67c"),
)
def test_issue_and_document_queries_use_verified_fields(self):
class Client:
def run(self, query, variables):
self.query, self.variables = query, variables
return {"issue": {"id": "issue-id"}}
client = Client()
self.assertEqual(cli.resolve_issue(client, "ENG-42"), {"id": "issue-id"})
self.assertIn("query Issue($id: String!)", client.query)
self.assertIn("issue(id: $id)", client.query)
self.assertNotIn("issueByIdentifier", client.query)
class StateClient:
def run(self, query, variables):
self.query, self.variables = query, variables
return {
"team": {
"states": {
"nodes": [
{"id": "state-id", "name": "Done", "type": "completed"}
]
}
}
}
state_client = StateClient()
self.assertEqual(
cli.resolve_state(
state_client, {"team": {"id": "team-id", "key": "ENG"}}, "Done"
),
{"id": "state-id", "name": "Done", "type": "completed"},
)
self.assertIn(
"query TeamStates($id: String!, $first: Int!)", state_client.query
)
self.assertIn("states(first: $first)", state_client.query)
self.assertEqual(state_client.variables, {"id": "team-id", "first": 100})
uuid = "123e4567-e89b-12d3-a456-426614174000"
query, variables = cli.document_query(uuid)
self.assertIn("query Document($ref: String!)", query)
self.assertIn("document(id: $ref)", query)
self.assertEqual(variables, {"ref": uuid})
query, variables = cli.document_query(
"https://linear.app/acme/document/my-roadmap-38359beef67c"
)
self.assertIn("documents(filter: { slugId: { eq: $ref } }, first: 1)", query)
self.assertNotIn("documentBySlugId", query)
self.assertEqual(variables, {"ref": "38359beef67c"})
def test_issue_list_builds_team_and_state_filters_independently(self):
class Client:
def run(self, query, variables):
self.query, self.variables = query, variables
return {"issues": {"nodes": []}}
client = Client()
parser = cli.build_parser()
args = parser.parse_args(
cli.extract_globals(
["issue", "list", "--team", "ENG", "--state", "Started", "--json"]
)
)
with redirect_stdout(io.StringIO()):
cli.issue_list(client, args)
self.assertIn("team: { key: { eq: $team } }", client.query)
self.assertIn("state: { name: { eq: $state } }", client.query)
self.assertEqual(
client.variables, {"first": 10, "team": "ENG", "state": "Started"}
)
def test_issue_update_and_move_use_verified_id_variable_type(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"}}
}
if query.startswith("query TeamStates"):
return {
"team": {
"states": {
"nodes": [
{"id": "state-id", "name": "Done", "type": "completed"}
]
}
}
}
return {"issueUpdate": {"success": True, "issue": {"id": "issue-id"}}}
try:
cli.Client.run = run
update = self.run_cli(
[
"issue",
"update",
"ENG-42",
"--title",
"Updated",
"--confirm",
"--json",
]
)
move = self.run_cli(
["issue", "move", "ENG-42", "--state", "Done", "--confirm", "--json"]
)
finally:
cli.Client.run = original
self.assertEqual(update[0], 0, update[2])
self.assertEqual(move[0], 0, move[2])
issue_update_queries = [
query for query in queries if query.startswith("mutation IssueUpdate")
]
self.assertEqual(len(issue_update_queries), 2)
for query in issue_update_queries:
self.assertIn(
"mutation IssueUpdate($id: String!, $input: IssueUpdateInput!)", query
)
def test_document_get_emits_first_slug_match(self):
original = cli.Client.run
try:
cli.Client.run = lambda _self, _query, _variables: {
"documents": {"nodes": [{"id": "document-id"}]}
}
code, output, error = self.run_cli(
[
"document",
"get",
"https://linear.app/acme/document/my-roadmap-38359beef67c",
"--json",
]
)
finally:
cli.Client.run = original
self.assertEqual(code, 0, error)
self.assertEqual(json.loads(output), {"id": "document-id"})
def test_document_get_emits_bare_slug_match(self):
original = cli.Client.run
try:
cli.Client.run = lambda _self, _query, _variables: {
"documents": {"nodes": [{"id": "document-id"}]}
}
code, output, error = self.run_cli(
["document", "get", "roadmap-q3", "--json"]
)
finally:
cli.Client.run = original
self.assertEqual(code, 0, error)
self.assertEqual(json.loads(output), {"id": "document-id"})
def test_missing_single_object_reads_fail(self):
original = cli.Client.run
try:
cli.Client.run = lambda _self, _query, _variables: {
"issue": None,
"document": None,
"documents": {"nodes": []},
"cycle": None,
}
cases = (
(["issue", "get", "ENG-42", "--detail", "--json"], "issue 'ENG-42'"),
(
[
"document",
"get",
"123e4567-e89b-12d3-a456-426614174000",
"--json",
],
"document '123e4567-e89b-12d3-a456-426614174000'",
),
(["document", "get", "roadmap-q3", "--json"], "document 'roadmap-q3'"),
(["cycle", "get", "cycle-id", "--json"], "cycle 'cycle-id'"),
)
for arguments, message in cases:
with self.subTest(arguments=arguments):
code, output, error = self.run_cli(arguments)
self.assertEqual(code, 2)
self.assertEqual(output, "")
self.assertIn(message + " was not found", error)
finally:
cli.Client.run = original
def test_documented_issue_create_and_move_forms_parse(self):
parser = cli.build_parser()
create = parser.parse_args(
["issue", "create", "--team", "ENG", "--title", "Ship it"]
)
move = parser.parse_args(["issue", "move", "ENG-42", "--state", "Done"])
self.assertEqual(
(create.noun, create.action, create.team, create.title),
("issue", "create", "ENG", "Ship it"),
)
self.assertEqual(
(move.noun, move.action, move.issue, move.state),
("issue", "move", "ENG-42", "Done"),
)
def test_dry_run_needs_no_credentials_or_network(self):
old_key, old_token = (
os.environ.pop("LINEAR_API_KEY", None),
os.environ.pop("LINEAR_ACCESS_TOKEN", None),
)
original = cli.urlopen
try:
def network_call(*_args, **_kwargs):
raise AssertionError("dry-run made a network call")
cli.urlopen = network_call
code, output, error = self.run_cli(
[
"issue",
"create",
"--team",
"ENG",
"--title",
"Preview",
"--dry-run",
"--json",
]
)
finally:
cli.urlopen = original
if old_key is not None:
os.environ["LINEAR_API_KEY"] = old_key
if old_token is not None:
os.environ["LINEAR_ACCESS_TOKEN"] = old_token
self.assertEqual(code, 0, error)
self.assertTrue(json.loads(output)["dry_run"])
def test_update_requires_a_field_before_network_or_confirmation(self):
original = cli.urlopen
try:
cli.urlopen = lambda *_args, **_kwargs: self.fail(
"invalid update made a network call"
)
code, output, error = self.run_cli(["issue", "update", "ENG-42", "--json"])
finally:
cli.urlopen = original
self.assertEqual(code, 2)
self.assertEqual(output, "")
self.assertIn("at least one field", error)
def test_is_mutation_rejects_query(self):
self.assertFalse(cli.is_mutation("query { viewer { id } }"))
def test_is_mutation_accepts_mutation(self):
self.assertTrue(
cli.is_mutation('mutation { issueArchive(id: "x") { success } }')
)
def test_is_mutation_strips_comments(self):
self.assertTrue(
cli.is_mutation('# comment\nmutation { issueArchive(id: "x") { success } }')
)
def test_is_mutation_finds_mutation_after_fragment(self):
self.assertTrue(
cli.is_mutation(
'fragment F on Issue { id }\nmutation { issueArchive(id: "x") { success } }'
)
)
def test_raw_with_comment_mutation_exits_confirm(self):
code, _output, error = self.run_cli(
["raw", '# comment\nmutation { issueArchive(id: "x") { success } }']
)
self.assertEqual(code, 6)
self.assertIn("--confirm", error)
def test_project_and_cycle_parsers(self):
parser = cli.build_parser()
project_list = parser.parse_args(["project", "list", "--team", "ENG"])
project_get = parser.parse_args(["project", "get", "Roadmap"])
cycle_list = parser.parse_args(["cycle", "list", "--team", "ENG"])
cycle_get = parser.parse_args(["cycle", "get", "cycle-id"])
self.assertEqual(
(project_list.noun, project_list.action, project_list.team),
("project", "list", "ENG"),
)
self.assertEqual(
(project_get.noun, project_get.action, project_get.project),
("project", "get", "Roadmap"),
)
self.assertEqual(
(cycle_list.noun, cycle_list.action, cycle_list.team),
("cycle", "list", "ENG"),
)
self.assertEqual(
(cycle_get.noun, cycle_get.action, cycle_get.cycle),
("cycle", "get", "cycle-id"),
)
def test_authorization_rejects_multiple_credentials(self):
old_key, old_token = (
os.environ.get("LINEAR_API_KEY"),
os.environ.get("LINEAR_ACCESS_TOKEN"),
)
os.environ["LINEAR_API_KEY"] = "key-value"
os.environ["LINEAR_ACCESS_TOKEN"] = "token-value"
stderr = io.StringIO()
try:
with redirect_stderr(stderr):
with self.assertRaises(SystemExit) as exc:
cli.Client(None).authorization()
finally:
if old_key is None:
os.environ.pop("LINEAR_API_KEY", None)
else:
os.environ["LINEAR_API_KEY"] = old_key
if old_token is None:
os.environ.pop("LINEAR_ACCESS_TOKEN", None)
else:
os.environ["LINEAR_ACCESS_TOKEN"] = old_token
self.assertEqual(exc.exception.code, 3)
self.assertIn("exactly one", stderr.getvalue())
self.assertNotIn("key-value", stderr.getvalue())
self.assertNotIn("token-value", stderr.getvalue())
def test_project_and_cycle_queries_use_verified_contracts(self):
self.assertEqual(
cli.PROJECT_FIELDS,
"id name description status { id name type } progress teams(first: 10) { nodes { id name key } } url",
)
self.assertEqual(
cli.CYCLE_FIELDS,
"id number name description startsAt endsAt progress team { id name key }",
)
class Client:
def run(self, query, variables):
self.query, self.variables = query, variables
return {"projects": {"nodes": []}}
client = Client()
args = cli.build_parser().parse_args(
["--json", "project", "list", "--team", "ENG"]
)
with redirect_stdout(io.StringIO()):
cli.project_list(client, args)
self.assertIn("accessibleTeams: { some: { key: { eq: $team } } }", client.query)
self.assertNotIn("filter: { team:", client.query)
self.assertEqual(client.variables, {"first": 10, "team": "ENG"})
def test_resolve_project_uses_direct_id_and_exact_name_queries(self):
project_id = "123e4567-e89b-12d3-a456-426614174000"
class IdClient:
def run(self, query, variables):
self.query, self.variables = query, variables
return {"project": {"id": project_id}}
id_client = IdClient()
self.assertEqual(cli.resolve_project(id_client, project_id), {"id": project_id})
self.assertIn("query Project($id: String!)", id_client.query)
self.assertIn("project(id: $id)", id_client.query)
self.assertEqual(id_client.variables, {"id": project_id})
class NameClient:
def run(self, query, variables):
self.query, self.variables = query, variables
return {
"projects": {"nodes": [{"id": "project-id", "name": "Roadmap"}]}
}
name_client = NameClient()
self.assertEqual(
cli.resolve_project(name_client, "Roadmap"),
{"id": "project-id", "name": "Roadmap"},
)
self.assertIn(
"projects(first: 2, filter: { name: { eq: $name } })", name_client.query
)
self.assertEqual(name_client.variables, {"name": "Roadmap"})
def test_leaf_help_includes_examples(self):
paths = [
["whoami"],
["raw"],
["team", "list"],
*(
["issue", action]
for action in (
"list",
"search",
"get",
"create",
"update",
"move",
"comment",
)
),
*(["document", action] for action in ("list", "search", "get")),
*(
[noun, action]
for noun in ("project", "cycle")
for action in ("list", "get")
),
]
for path in paths:
with self.subTest(path=path):
code, output, error = self.run_cli(path + ["--help"])
self.assertEqual(code, 0, error)
self.assertIn("Example:", output)
self.assertIn("--json", output)
self.assertIn("--dry-run", output)
self.assertIn("--limit LIMIT", output)
self.assertIn("1-100", output)
def test_update_and_comment_dry_runs_include_requested_intent(self):
code, output, error = self.run_cli(
[
"issue",
"update",
"ENG-42",
"--title",
"Updated",
"--priority",
"high",
"--dry-run",
"--json",
]
)
self.assertEqual(code, 0, error)
update = json.loads(output)["operations"][-1]
self.assertEqual(
update,
{
"operation": "issueUpdate",
"issue": "ENG-42",
"input": {"title": "Updated", "priority": "high"},
},
)
code, output, error = self.run_cli(
[
"issue",
"comment",
"ENG-42",
"--body",
"Ready for review",
"--dry-run",
"--json",
]
)
self.assertEqual(code, 0, error)
self.assertEqual(
json.loads(output)["operations"][-1],
{
"operation": "commentCreate",
"issue": "ENG-42",
"body": "Ready for review",
},
)
def test_issue_detail_dry_run_includes_detail_intent(self):
code, output, error = self.run_cli(
["issue", "get", "ENG-42", "--detail", "--dry-run", "--json"]
)
self.assertEqual(code, 0, error)
self.assertEqual(
json.loads(output)["operations"],
[{"operation": "resolve issue", "reference": "ENG-42", "detail": True}],
)
def test_extract_globals_moves_flags_and_requires_limit_value(self):
self.assertEqual(
cli.extract_globals(
[
"issue",
"list",
"--json",
"--team",
"ENG",
"--dry-run",
"--limit",
"20",
]
),
["--json", "--dry-run", "--limit", "20", "issue", "list", "--team", "ENG"],
)
with redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as exc:
cli.extract_globals(["issue", "list", "--limit"])
self.assertEqual(exc.exception.code, 2)
def test_validate_limit_boundaries(self):
for limit in (1, 100):
with self.subTest(limit=limit):
cli.validate_limit(type("Args", (), {"limit": limit})())
for limit in (0, 101):
with self.subTest(limit=limit):
with redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as exc:
cli.validate_limit(type("Args", (), {"limit": limit})())
self.assertEqual(exc.exception.code, 2)
def test_resolve_team_matches_key_or_name_and_rejects_ambiguity(self):
class Client:
def __init__(self, teams):
self.teams = teams
def run(self, _query, _variables):
return {"teams": {"nodes": self.teams}}
teams = [
{"id": "eng", "key": "ENG", "name": "Engineering"},
{"id": "ops", "key": "OPS", "name": "Operations"},
]
self.assertEqual(cli.resolve_team(Client(teams), "eng")["id"], "eng")
self.assertEqual(cli.resolve_team(Client(teams), "engineering")["id"], "eng")
for team, candidates in (
("missing", teams),
(
"engineering",
teams + [{"id": "platform", "key": "PLAT", "name": "Engineering"}],
),
):
with self.subTest(team=team):
with redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as exc:
cli.resolve_team(Client(candidates), team)
self.assertEqual(exc.exception.code, 2)
def test_issue_create_resolves_team_before_mutation(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 {"issueCreate": {"success": True, "issue": {"id": "issue-id"}}}
try:
cli.Client.run = run
code, _output, error = self.run_cli(
[
"issue",
"create",
"--team",
"Engineering",
"--title",
"Ship it",
"--confirm",
"--json",
]
)
finally:
cli.Client.run = original
self.assertEqual(code, 0, error)
self.assertEqual(calls[-1][1]["input"]["teamId"], "team-id")
def test_resolve_state_reports_available_names(self):
class Client:
def run(self, _query, _variables):
return {
"team": {
"states": {
"nodes": [
{"id": "todo", "name": "Todo", "type": "backlog"},
{"id": "done", "name": "Done", "type": "completed"},
]
}
}
}
stderr = io.StringIO()
with redirect_stderr(stderr):
with self.assertRaises(SystemExit):
cli.resolve_state(
Client(), {"team": {"id": "team-id", "key": "ENG"}}, "Missing"
)
self.assertIn("available states: Todo, Done", stderr.getvalue())
def test_json_separates_stderr(self):
code, output, error = self.run_cli(
["raw", "query { viewer { id } }", "--variables", "[]", "--json"]
)
self.assertEqual(code, 2)
self.assertEqual(output, "")
self.assertIn("--variables must be a JSON object", error)
def test_graphql_errors_with_http_200(self):
original = cli.Client.run
try:
cli.Client.run = lambda self, _query, _variables=None: self.handle_response(
{"errors": [{"message": "test"}]}
)
code, output, error = self.run_cli(["whoami", "--json"])
finally:
cli.Client.run = original
self.assertEqual(code, 5)
self.assertEqual(output, "")
self.assertIn("GraphQL error: test", error)
if __name__ == "__main__":
unittest.main()