diff --git a/AGENTS.md b/AGENTS.md index 2e0d5ab..d73b8a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,7 @@ When the user mentions these keywords, load the corresponding skill: | "kanban", "WIP", "cycle time", "flow metrics", "Scrum to Kanban", "multi-portfolio", "throughput", "classes of service" | [kanban-guru](kanban-guru/SKILL.md) | | "kubernetes", "k8s", "kubectl", "k3s", "RKE2", "MicroK8s", "k0s", "Talos", "OpenShift", "EKS", "AKS", "GKE", "Pod", "Deployment", "StatefulSet", "CRD", "RBAC", "NetworkPolicy", "Helm on Kubernetes", "cluster upgrade", "Kubernetes troubleshooting" | [kubernetes](kubernetes/SKILL.md) | | "langgraph", "multi-agent", "state machine", "graph-based workflow", "LangGraph", "supervisor pattern", "swarm pattern", "agent orchestration", "graph state", "subgraph", "agent routing", "tool-calling loop", "agent loop", "stateful agent", "durable execution", "human in the loop langgraph", "checkpointer", "langgraph persistence" | [langgraph](langgraph/SKILL.md) | +| "Linear", "Linear API", "Linear issue", "Linear project", "Linear cycle", "Linear comment", "Linear document" | [linear](linear/SKILL.md) | | "debate", "council", "multi-perspective", "structured debate", "get multiple perspectives", "expert panel", "decision landscape", "what would experts say", "what are we missing", "convergence", "false consensus", "agent-council", "pre-mortem" | [agent-council](agent-council/SKILL.md) | | "skill format", "how do I make a skill", "agentskills.io" | [agent-skills](agent-skills/SKILL.md) | | "Fireflies", "Fireflies.ai", "meeting transcripts", "meeting notes", "audio upload", "webhook verification", "AskFred", "AI meeting analytics" | [fireflies](fireflies/SKILL.md) | diff --git a/README.md b/README.md index 3a4fbda..03b50b2 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,10 @@ Last.fm music data API from the terminal. Lookup user listening history, get art Manage your Lidarr music library from the terminal — search and browse artists and albums, add new artists, check calendars, view queue and download history. +### [linear](linear/SKILL.md) + +Work with Linear teams, projects, cycles, issues, comments, workflow transitions, and documents using a small, dependency-free GraphQL CLI with bounded reads, dry-run previews, and focused reference guidance. + ### [llamaindex](llamaindex/SKILL.md) An expert-level skill for building LLM applications over your data with LlamaIndex. RAG pipelines, multi-agent orchestration, event-driven workflows, knowledge graph construction, and production deployment. diff --git a/linear/README.md b/linear/README.md new file mode 100644 index 0000000..15850c2 --- /dev/null +++ b/linear/README.md @@ -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 diff --git a/linear/SKILL.md b/linear/SKILL.md new file mode 100644 index 0000000..ecac663 --- /dev/null +++ b/linear/SKILL.md @@ -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. diff --git a/linear/references/domain-and-workflows.md b/linear/references/domain-and-workflows.md new file mode 100644 index 0000000..7a90a1a --- /dev/null +++ b/linear/references/domain-and-workflows.md @@ -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. diff --git a/linear/references/graphql-contract.md b/linear/references/graphql-contract.md new file mode 100644 index 0000000..86e2083 --- /dev/null +++ b/linear/references/graphql-contract.md @@ -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. diff --git a/linear/references/integration-boundaries.md b/linear/references/integration-boundaries.md new file mode 100644 index 0000000..b844b6c --- /dev/null +++ b/linear/references/integration-boundaries.md @@ -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`. diff --git a/linear/references/sources.md b/linear/references/sources.md new file mode 100644 index 0000000..f4b5154 --- /dev/null +++ b/linear/references/sources.md @@ -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. diff --git a/linear/scripts/linear b/linear/scripts/linear new file mode 100755 index 0000000..08b86cf --- /dev/null +++ b/linear/scripts/linear @@ -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 .", + description="Example: linear issue get ENG-42 or linear issue get .", + ) + 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 , linear document get , 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() diff --git a/linear/tests/test_linear.py b/linear/tests/test_linear.py new file mode 100644 index 0000000..f782fa1 --- /dev/null +++ b/linear/tests/test_linear.py @@ -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()