Files
magnus919_agent-skills/linear/scripts/linear
T
Magnus HedemarkGitHubfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
b8a5092c26 feat(linear): project mutations and richer issue verbs in CLI (#288)
## What this adds

Implements the request in #287 and the Tier 1 audit gaps for the `linear` skill's `scripts/linear` CLI, reconciled against the live Linear GraphQL schema.

### New verbs
- `linear project update` — name, description, status, start/target dates, priority, with the same `--dry-run`/`--confirm` gate as issue mutations, and a local 255-character description guard matching Linear's `projectUpdate` limit (Linear rejects longer descriptions with a generic error).
- `linear issue archive` / `linear issue unarchive` — both gated, returning `IssueArchivePayload.entity`.
- `linear state list --team ENG` — first-class workflow-state discovery (previously states were only visible in the `issue move` failure path).

### Richer issue verbs
- `issue create` now accepts `--project`, `--parent`, `--assignee`, `--label` (repeatable), `--state`, `--due`.
- `issue update` now accepts `--assignee`, `--label` (add), `--remove-label`, `--due`, `--project`.

### Resolution rules (all require exactly one match, mirroring `resolve_team`)
- Project: UUID or exact name
- Parent: issue identifier or UUID
- Assignee: exact name, display name, or email (via `users`)
- Label: exact name within the issue's team (via `team.labels`)
- Workflow state: exact name within the issue's team (existing `team.states` resolver, now reusable for `--state` on create)
- Project status: exact name or type (via `projectStatuses`)

### Docs, tests, evals
- SKILL.md command map, state-change gate, and error/recovery sections; README; `domain-and-workflows.md` (project semantics + 255-char limit), `graphql-contract.md` (resolution queries), `integration-boundaries.md` (intentional exclusions list), `sources.md` (2026-08-05 schema re-verification note).
- 15 new offline tests (45 total) covering resolution, gates, dry-run intent, payload shapes, and field guards.
- Added a sixth eval case (`safe-project-and-issue-mutations`).

## Validation
- `python3 -m unittest linear/tests/test_linear.py` — 45/45 pass
- `python3 scripts/validate-evals.py`, `ruby scripts/validate-skills.rb`, `python3 scripts/check-artifacts.py`, `python3 scripts/eval-coverage.py --modified-from origin/main`, skill-quality validator, marketplace/codex/llms freshness, jscpd — all green locally

Closes #287

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-05 16:43:36 -04:00

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()