mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-13 04:26:28 +03:00
b8a5092c26
## What this adds Implements the request in #287 and the Tier 1 audit gaps for the `linear` skill's `scripts/linear` CLI, reconciled against the live Linear GraphQL schema. ### New verbs - `linear project update` — name, description, status, start/target dates, priority, with the same `--dry-run`/`--confirm` gate as issue mutations, and a local 255-character description guard matching Linear's `projectUpdate` limit (Linear rejects longer descriptions with a generic error). - `linear issue archive` / `linear issue unarchive` — both gated, returning `IssueArchivePayload.entity`. - `linear state list --team ENG` — first-class workflow-state discovery (previously states were only visible in the `issue move` failure path). ### Richer issue verbs - `issue create` now accepts `--project`, `--parent`, `--assignee`, `--label` (repeatable), `--state`, `--due`. - `issue update` now accepts `--assignee`, `--label` (add), `--remove-label`, `--due`, `--project`. ### Resolution rules (all require exactly one match, mirroring `resolve_team`) - Project: UUID or exact name - Parent: issue identifier or UUID - Assignee: exact name, display name, or email (via `users`) - Label: exact name within the issue's team (via `team.labels`) - Workflow state: exact name within the issue's team (existing `team.states` resolver, now reusable for `--state` on create) - Project status: exact name or type (via `projectStatuses`) ### Docs, tests, evals - SKILL.md command map, state-change gate, and error/recovery sections; README; `domain-and-workflows.md` (project semantics + 255-char limit), `graphql-contract.md` (resolution queries), `integration-boundaries.md` (intentional exclusions list), `sources.md` (2026-08-05 schema re-verification note). - 15 new offline tests (45 total) covering resolution, gates, dry-run intent, payload shapes, and field guards. - Added a sixth eval case (`safe-project-and-issue-mutations`). ## Validation - `python3 -m unittest linear/tests/test_linear.py` — 45/45 pass - `python3 scripts/validate-evals.py`, `ruby scripts/validate-skills.rb`, `python3 scripts/check-artifacts.py`, `python3 scripts/eval-coverage.py --modified-from origin/main`, skill-quality validator, marketplace/codex/llms freshness, jscpd — all green locally Closes #287 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1159 lines
42 KiB
Python
Executable File
1159 lines
42 KiB
Python
Executable File
#!/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
|
|
)
|
|
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
ISSUE_FIELDS = "id identifier title description priority url createdAt updatedAt team { id name key } state { id name type } assignee { id name } labels { nodes { id name } }"
|
|
ISSUE_DETAIL_FIELDS = (
|
|
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 parse_date(value, label):
|
|
if not DATE_RE.match(value):
|
|
fail("%s must be an ISO date in YYYY-MM-DD form" % label)
|
|
return value
|
|
|
|
|
|
def validate_project_description(value):
|
|
if len(value) > 255:
|
|
fail(
|
|
"project description must be 255 characters or fewer; "
|
|
"Linear's projectUpdate API rejects longer descriptions"
|
|
)
|
|
return value
|
|
|
|
|
|
def is_mutation(query):
|
|
index = 0
|
|
braces = parens = brackets = 0
|
|
definition = None
|
|
while index < len(query):
|
|
if query.startswith('"""', index):
|
|
index += 3
|
|
while index < len(query):
|
|
if query.startswith('"""', index):
|
|
cursor = index - 1
|
|
while cursor >= 0 and query[cursor] == "\\":
|
|
cursor -= 1
|
|
if (index - cursor - 1) % 2 == 0:
|
|
index += 3
|
|
break
|
|
index += 1
|
|
continue
|
|
if query[index] == '"':
|
|
index += 1
|
|
while index < len(query) and query[index] != '"':
|
|
index += 2 if query[index] == "\\" else 1
|
|
index += 1
|
|
continue
|
|
if query[index] == "#":
|
|
newline = query.find("\n", index)
|
|
index = len(query) if newline < 0 else newline + 1
|
|
continue
|
|
match = re.match(r"[_A-Za-z][_0-9A-Za-z]*", query[index:])
|
|
if match:
|
|
token = match.group(0).lower()
|
|
if not (braces or parens or brackets) and definition is None:
|
|
if token == "mutation":
|
|
return True
|
|
if token in ("query", "subscription", "fragment"):
|
|
definition = token
|
|
index += len(match.group(0))
|
|
continue
|
|
char = query[index]
|
|
if char == "{":
|
|
braces += 1
|
|
elif char == "}":
|
|
braces = max(0, braces - 1)
|
|
if not (braces or parens or brackets):
|
|
definition = None
|
|
elif char == "(":
|
|
parens += 1
|
|
elif char == ")":
|
|
parens = max(0, parens - 1)
|
|
elif char == "[":
|
|
brackets += 1
|
|
elif char == "]":
|
|
brackets = max(0, brackets - 1)
|
|
index += 1
|
|
return False
|
|
|
|
|
|
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, team, state_name):
|
|
query = "query TeamStates($id: String!, $first: Int!) { team(id: $id) { states(first: $first) { nodes { id name type } } } }"
|
|
data = client.run(query, {"id": 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, 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]
|
|
|
|
|
|
def resolve_user(client, ref):
|
|
query = "query Users($first: Int!) { users(first: $first) { nodes { id name displayName email } } }"
|
|
data = client.run(query, {"first": MAX_LIMIT})
|
|
matches = [
|
|
user
|
|
for user in nodes(data, "users")
|
|
if user["id"] == ref
|
|
or user["name"].lower() == ref.lower()
|
|
or (user.get("displayName") or "").lower() == ref.lower()
|
|
or (user.get("email") or "").lower() == ref.lower()
|
|
]
|
|
if len(matches) != 1:
|
|
fail(
|
|
"user %r did not resolve to exactly one user; use an exact name or email"
|
|
% ref
|
|
)
|
|
return matches[0]
|
|
|
|
|
|
def resolve_label(client, team, name):
|
|
query = "query TeamLabels($id: String!, $first: Int!) { team(id: $id) { labels(first: $first) { nodes { id name } } } }"
|
|
data = client.run(query, {"id": team["id"], "first": MAX_LIMIT})
|
|
labels = (data.get("team") or {}).get("labels") or {}
|
|
labels = labels.get("nodes", labels) if isinstance(labels, dict) else labels
|
|
matches = [label for label in labels if label["name"].lower() == name.lower()]
|
|
if len(matches) != 1:
|
|
available = ", ".join(label["name"] for label in labels) or "none"
|
|
fail(
|
|
"label %r did not resolve in team %s; available labels: %s"
|
|
% (name, team["key"], available)
|
|
)
|
|
return matches[0]
|
|
|
|
|
|
def resolve_project_status(client, name):
|
|
query = "query ProjectStatuses($first: Int!) { projectStatuses(first: $first) { nodes { id name type } } }"
|
|
data = client.run(query, {"first": MAX_LIMIT})
|
|
statuses = nodes(data, "projectStatuses")
|
|
matches = [
|
|
status
|
|
for status in statuses
|
|
if status["name"].lower() == name.lower()
|
|
or status["type"].lower() == name.lower()
|
|
]
|
|
if len(matches) != 1:
|
|
available = ", ".join(status["name"] for status in statuses) or "none"
|
|
fail(
|
|
"project status %r did not resolve; available statuses: %s"
|
|
% (name, available)
|
|
)
|
|
return matches[0]
|
|
|
|
|
|
class LinearArgumentParser(argparse.ArgumentParser):
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs.setdefault("epilog", GLOBAL_OPTIONS_HELP)
|
|
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(
|
|
"--project", metavar="PROJECT", help="Project UUID or exact name to attach."
|
|
)
|
|
p.add_argument(
|
|
"--parent", metavar="ISSUE", help="Parent issue identifier or UUID for a sub-issue."
|
|
)
|
|
p.add_argument(
|
|
"--assignee", metavar="USER", help="Assignee exact name or email."
|
|
)
|
|
p.add_argument(
|
|
"--label",
|
|
action="append",
|
|
metavar="LABEL",
|
|
help="Label name in the issue's team (repeatable).",
|
|
)
|
|
p.add_argument(
|
|
"--state", metavar="STATE", help="Destination workflow state name in the team."
|
|
)
|
|
p.add_argument("--due", metavar="YYYY-MM-DD", help="Due date (ISO).")
|
|
p.add_argument("--confirm", action="store_true")
|
|
p = actions.add_parser(
|
|
"update",
|
|
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(
|
|
"--assignee", metavar="USER", help="Assignee exact name or email."
|
|
)
|
|
p.add_argument(
|
|
"--label",
|
|
action="append",
|
|
metavar="LABEL",
|
|
help="Label name in the issue's team to add (repeatable).",
|
|
)
|
|
p.add_argument(
|
|
"--remove-label",
|
|
action="append",
|
|
dest="remove_label",
|
|
metavar="LABEL",
|
|
help="Label name in the issue's team to remove (repeatable).",
|
|
)
|
|
p.add_argument("--due", metavar="YYYY-MM-DD", help="Due date (ISO).")
|
|
p.add_argument(
|
|
"--project", metavar="PROJECT", help="Project UUID or exact name to attach."
|
|
)
|
|
p.add_argument("--confirm", action="store_true")
|
|
p = actions.add_parser(
|
|
"move",
|
|
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")
|
|
p = actions.add_parser(
|
|
"archive",
|
|
help="Archive an issue.",
|
|
description="Example: linear issue archive ENG-42",
|
|
)
|
|
p.add_argument("issue")
|
|
p.add_argument("--confirm", action="store_true")
|
|
p = actions.add_parser(
|
|
"unarchive",
|
|
help="Unarchive an issue.",
|
|
description="Example: linear issue unarchive ENG-42",
|
|
)
|
|
p.add_argument("issue")
|
|
p.add_argument("--confirm", action="store_true")
|
|
document = root.add_parser("document", help="Read Linear documents.")
|
|
actions = document.add_subparsers(dest="action", required=True)
|
|
actions.add_parser(
|
|
"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")
|
|
p = actions.add_parser(
|
|
"update",
|
|
help="Update a project.",
|
|
description='Example: linear project update "Roadmap" --description "Q3 plan" --status started',
|
|
)
|
|
p.add_argument("project")
|
|
p.add_argument("--name")
|
|
p.add_argument("--description")
|
|
p.add_argument(
|
|
"--status",
|
|
help="Project status name or type (planned, started, paused, completed, canceled).",
|
|
)
|
|
p.add_argument("--start-date", dest="start_date", help="Start date (ISO YYYY-MM-DD).")
|
|
p.add_argument(
|
|
"--target-date", dest="target_date", help="Target date (ISO YYYY-MM-DD)."
|
|
)
|
|
p.add_argument("--priority", choices=("none", "urgent", "high", "medium", "low"))
|
|
p.add_argument("--confirm", action="store_true")
|
|
cycle = root.add_parser("cycle", help="Read Linear cycles.")
|
|
actions = cycle.add_subparsers(dest="action", required=True)
|
|
p = actions.add_parser(
|
|
"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")
|
|
state = root.add_parser("state", help="Read workflow states.")
|
|
actions = state.add_subparsers(dest="action", required=True)
|
|
p = actions.add_parser(
|
|
"list",
|
|
help="List workflow states for a team.",
|
|
description="Example: linear state list --team ENG --json",
|
|
)
|
|
p.add_argument(
|
|
"--team", required=True, metavar="TEAM_KEY", help="Team key or exact name."
|
|
)
|
|
return parser
|
|
|
|
|
|
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,
|
|
args.assignee,
|
|
args.label,
|
|
args.remove_label,
|
|
args.due,
|
|
args.project,
|
|
)
|
|
)
|
|
):
|
|
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:
|
|
operations = [
|
|
{"operation": "resolve team", "reference": args.team}
|
|
]
|
|
if args.project:
|
|
operations.append(
|
|
{"operation": "resolve project", "reference": args.project}
|
|
)
|
|
if args.parent:
|
|
operations.append(
|
|
{"operation": "resolve parent issue", "reference": args.parent}
|
|
)
|
|
if args.assignee:
|
|
operations.append(
|
|
{"operation": "resolve assignee", "reference": args.assignee}
|
|
)
|
|
if args.label:
|
|
operations.append(
|
|
{"operation": "resolve labels", "names": args.label}
|
|
)
|
|
if args.state:
|
|
operations.append(
|
|
{"operation": "resolve state in team", "name": args.state}
|
|
)
|
|
operations.append(
|
|
{
|
|
"operation": "issueCreate",
|
|
"input": {
|
|
"team": args.team,
|
|
"title": args.title,
|
|
"description": args.description,
|
|
"priority": args.priority,
|
|
"project": args.project,
|
|
"parent": args.parent,
|
|
"assignee": args.assignee,
|
|
"labels": args.label,
|
|
"state": args.state,
|
|
"due": args.due,
|
|
},
|
|
}
|
|
)
|
|
preview(args, operations, 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),
|
|
}
|
|
if args.project:
|
|
inp["projectId"] = resolve_project(client, args.project)["id"]
|
|
if args.parent:
|
|
inp["parentId"] = resolve_issue(client, args.parent)["id"]
|
|
if args.assignee:
|
|
inp["assigneeId"] = resolve_user(client, args.assignee)["id"]
|
|
if args.label:
|
|
inp["labelIds"] = [
|
|
resolve_label(client, team, name)["id"] for name in args.label
|
|
]
|
|
if args.state:
|
|
inp["stateId"] = resolve_state(client, team, args.state)["id"]
|
|
if args.due:
|
|
inp["dueDate"] = parse_date(args.due, "--due")
|
|
emit(
|
|
client.run(
|
|
"mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { %s } } }"
|
|
% ISSUE_FIELDS,
|
|
{
|
|
"input": {
|
|
key: value
|
|
for key, value in inp.items()
|
|
if value is not None
|
|
}
|
|
},
|
|
),
|
|
args,
|
|
)
|
|
return
|
|
if args.action in ("archive", "unarchive"):
|
|
mutation = "issueArchive" if args.action == "archive" else "issueUnarchive"
|
|
if args.dry_run:
|
|
preview(
|
|
args,
|
|
[
|
|
{"operation": "resolve issue", "reference": args.issue},
|
|
{"operation": mutation, "issue": args.issue},
|
|
],
|
|
mutation=True,
|
|
)
|
|
return
|
|
issue = resolve_issue(client, args.issue)
|
|
emit(
|
|
client.run(
|
|
"mutation IssueArchive($id: String!) { %s(id: $id) { success entity { %s } } }"
|
|
% (mutation, ISSUE_FIELDS),
|
|
{"id": issue["id"]},
|
|
),
|
|
args,
|
|
)
|
|
return
|
|
if args.dry_run:
|
|
operations = [{"operation": "resolve issue", "reference": args.issue}]
|
|
if args.action == "move":
|
|
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,
|
|
"assignee": args.assignee,
|
|
"add_labels": args.label,
|
|
"remove_labels": args.remove_label,
|
|
"due": args.due,
|
|
"project": args.project,
|
|
}.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),
|
|
}
|
|
team = issue["team"]
|
|
if args.assignee:
|
|
inp["assigneeId"] = resolve_user(client, args.assignee)["id"]
|
|
if args.label:
|
|
inp["addedLabelIds"] = [
|
|
resolve_label(client, team, name)["id"] for name in args.label
|
|
]
|
|
if args.remove_label:
|
|
inp["removedLabelIds"] = [
|
|
resolve_label(client, team, name)["id"] for name in args.remove_label
|
|
]
|
|
if args.due:
|
|
inp["dueDate"] = parse_date(args.due, "--due")
|
|
if args.project:
|
|
inp["projectId"] = resolve_project(client, args.project)["id"]
|
|
inp = {key: value for key, value in inp.items() if value is not None}
|
|
if not inp:
|
|
fail("issue update requires at least one field to change")
|
|
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
|
|
if args.action == "update":
|
|
if all(
|
|
value is None
|
|
for value in (
|
|
args.name,
|
|
args.description,
|
|
args.status,
|
|
args.start_date,
|
|
args.target_date,
|
|
args.priority,
|
|
)
|
|
):
|
|
fail("project update requires at least one field to change")
|
|
if args.description:
|
|
validate_project_description(args.description)
|
|
require_confirmation(args)
|
|
if args.dry_run:
|
|
operations = [
|
|
{"operation": "resolve project", "reference": args.project}
|
|
]
|
|
if args.status:
|
|
operations.append(
|
|
{
|
|
"operation": "resolve project status",
|
|
"reference": args.status,
|
|
}
|
|
)
|
|
operations.append(
|
|
{
|
|
"operation": "projectUpdate",
|
|
"project": args.project,
|
|
"input": {
|
|
key: value
|
|
for key, value in {
|
|
"name": args.name,
|
|
"description": args.description,
|
|
"status": args.status,
|
|
"start_date": args.start_date,
|
|
"target_date": args.target_date,
|
|
"priority": args.priority,
|
|
}.items()
|
|
if value is not None
|
|
},
|
|
}
|
|
)
|
|
preview(args, operations, mutation=True)
|
|
return
|
|
project = resolve_project(client, args.project)
|
|
inp = {
|
|
"name": args.name,
|
|
"description": args.description,
|
|
"priority": {
|
|
"none": 0,
|
|
"urgent": 1,
|
|
"high": 2,
|
|
"medium": 3,
|
|
"low": 4,
|
|
}.get(args.priority),
|
|
}
|
|
if args.status:
|
|
inp["statusId"] = resolve_project_status(client, args.status)["id"]
|
|
if args.start_date:
|
|
inp["startDate"] = parse_date(args.start_date, "--start-date")
|
|
if args.target_date:
|
|
inp["targetDate"] = parse_date(args.target_date, "--target-date")
|
|
inp = {key: value for key, value in inp.items() if value is not None}
|
|
emit(
|
|
client.run(
|
|
"mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) { projectUpdate(id: $id, input: $input) { success project { %s } } }"
|
|
% PROJECT_FIELDS,
|
|
{"id": project["id"], "input": inp},
|
|
),
|
|
args,
|
|
)
|
|
return
|
|
emit(resolve_project(client, args.project), args)
|
|
return
|
|
if args.noun == "cycle":
|
|
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 args.noun == "state":
|
|
if args.dry_run:
|
|
preview(
|
|
args,
|
|
[
|
|
{"operation": "resolve team", "reference": args.team},
|
|
{"operation": "list team states", "team": args.team},
|
|
],
|
|
)
|
|
return
|
|
team = resolve_team(client, args.team)
|
|
emit(
|
|
client.run(
|
|
"query TeamStates($id: String!, $first: Int!) { team(id: $id) { states(first: $first) { nodes { id name type description } } } }",
|
|
{"id": team["id"], "first": args.limit},
|
|
),
|
|
args,
|
|
)
|
|
return
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|