mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
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:
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
Executable
+772
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user