mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-14 13:06:30 +03:00
Credentialed four-epoch SkillOpt validation. Exact-head independent review passed; validate check green.
824 lines
29 KiB
Python
Executable File
824 lines
29 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
|
|
)
|
|
|
|
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):
|
|
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, 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()
|