mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(skills/linear): add Documents support + Python helper script (#20752)
* feat(skills/linear): add Documents support + Python helper script The bundled Linear skill (PR #1230) covered issues, projects, teams, and workflow states via curl. It had no coverage for Linear's Documents API, so fetching an RFC/doc from a linear.app URL required hand-writing GraphQL against an underdocumented schema. Adds: - Documents section in SKILL.md explaining slugId extraction from URLs, the contentState (markdown) vs contentState (ProseMirror) split, and four canonical curl examples (fetch by slugId, fetch by UUID, list recent, title-search). - scripts/linear_api.py — stdlib-only Python CLI wrapping the most common operations (whoami, list-teams, list/get/search/create/update issues, add-comment, update-status, list/get/search documents, raw GraphQL passthrough). Zero deps, reads LINEAR_API_KEY from env. Auth header quirk (personal key takes bare $LINEAR_API_KEY, no Bearer prefix) is already documented in the skill. Found during RFC review: the existing skill's lack of document support forced falling back to the browser (which hit Linear's login wall). Also fixes a schema gotcha — the Document field is `contentState`, not `contentData` (which returns 400). Tested end-to-end against the production API: python3 linear_api.py whoami python3 linear_api.py get-document 38359beef67c Both return expected payloads. * fix(skills/linear): point LINEAR_API_KEY setup to the correct page The org-level Settings > API page (/settings/api) only shows OAuth apps and workspace-member keys. Personal API keys live under Account, Security, access (/settings/account/security). Update both the setup link in config.py (shown during hermes setup) and the setup step in SKILL.md so users land on the page that can create a personal key.
This commit is contained in:
parent
b62a82e0c3
commit
ad7aad251c
4 changed files with 612 additions and 3 deletions
|
|
@ -1944,7 +1944,7 @@ OPTIONAL_ENV_VARS = {
|
||||||
"LINEAR_API_KEY": {
|
"LINEAR_API_KEY": {
|
||||||
"description": "Linear personal API key (used by the `linear` skill)",
|
"description": "Linear personal API key (used by the `linear` skill)",
|
||||||
"prompt": "Linear API key",
|
"prompt": "Linear API key",
|
||||||
"url": "https://linear.app/settings/api",
|
"url": "https://linear.app/settings/account/security",
|
||||||
"password": True,
|
"password": True,
|
||||||
"category": "skill",
|
"category": "skill",
|
||||||
"advanced": True,
|
"advanced": True,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ Manage Linear issues, projects, and teams directly via the GraphQL API using `cu
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Get a personal API key from **Linear Settings > API > Personal API keys**
|
1. Get a personal API key from **Linear Settings > Account > Security & access > Personal API keys** (URL: https://linear.app/settings/account/security). Note: the org-level *Settings > API* page only shows OAuth apps and workspace-member keys, not personal keys.
|
||||||
2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config)
|
2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config)
|
||||||
|
|
||||||
## API Basics
|
## API Basics
|
||||||
|
|
@ -36,6 +36,24 @@ curl -s -X POST https://api.linear.app/graphql \
|
||||||
-d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool
|
-d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Python helper script (ergonomic alternative)
|
||||||
|
|
||||||
|
For faster one-liners that don't need hand-written GraphQL, this skill ships a stdlib Python CLI at `scripts/linear_api.py`. Zero dependencies. Same auth (reads `LINEAR_API_KEY`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT=$(dirname "$(find ~/.hermes -path '*skills/productivity/linear/scripts/linear_api.py' 2>/dev/null | head -1)")/linear_api.py
|
||||||
|
|
||||||
|
python3 "$SCRIPT" whoami
|
||||||
|
python3 "$SCRIPT" list-teams
|
||||||
|
python3 "$SCRIPT" get-issue ENG-42
|
||||||
|
python3 "$SCRIPT" get-document 38359beef67c # fetch a doc by slugId from the URL
|
||||||
|
python3 "$SCRIPT" raw 'query { viewer { name } }'
|
||||||
|
```
|
||||||
|
|
||||||
|
All subcommands: `whoami`, `list-teams`, `list-projects`, `list-states`, `list-issues`, `get-issue`, `search-issues`, `create-issue`, `update-issue`, `update-status`, `add-comment`, `list-documents`, `get-document`, `search-documents`, `raw`. Run with `--help` for flags.
|
||||||
|
|
||||||
|
Use the script when: you want a quick answer without crafting GraphQL. Use curl when: you need a query the script doesn't wrap, or you want to compose filters inline.
|
||||||
|
|
||||||
## Workflow States
|
## Workflow States
|
||||||
|
|
||||||
Linear uses `WorkflowState` objects with a `type` field. **6 state types:**
|
Linear uses `WorkflowState` objects with a `type` field. **6 state types:**
|
||||||
|
|
@ -245,6 +263,70 @@ curl -s -X POST https://api.linear.app/graphql \
|
||||||
}' | python3 -m json.tool
|
}' | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
Linear **Documents** are prose docs (RFCs, specs, notes) stored alongside issues. They have their own `documents` root query and `document(id:)` single-fetch.
|
||||||
|
|
||||||
|
### Document URLs and `slugId`
|
||||||
|
|
||||||
|
Document URLs look like:
|
||||||
|
```
|
||||||
|
https://linear.app/<workspace>/document/<slug>-<hexSlugId>
|
||||||
|
```
|
||||||
|
|
||||||
|
The trailing hex segment is the `slugId`. Example: `https://linear.app/nousresearch/document/rfc-hermes-permission-gateway-discord-38359beef67c` → `slugId` is `38359beef67c`.
|
||||||
|
|
||||||
|
**Important schema detail:** the Markdown body is in the `content` field. The ProseMirror JSON is in `contentState` (not `contentData` — that field does not exist and the API returns 400).
|
||||||
|
|
||||||
|
### Fetch a document by slugId
|
||||||
|
|
||||||
|
`document(id:)` only accepts UUIDs. To fetch by the URL's hex slug, filter the collection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.linear.app/graphql \
|
||||||
|
-H "Authorization: $LINEAR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "query($s: String!) { documents(filter: { slugId: { eq: $s } }, first: 1) { nodes { id title content contentState slugId url creator { name } project { name } updatedAt } } }", "variables": {"s": "38359beef67c"}}' \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via the Python helper:
|
||||||
|
```bash
|
||||||
|
python3 scripts/linear_api.py get-document 38359beef67c
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch a document by UUID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.linear.app/graphql \
|
||||||
|
-H "Authorization: $LINEAR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "{ document(id: \"11700cff-b514-4db3-afcc-3ed1afacba1c\") { title content url } }"}' \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### List recent documents
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.linear.app/graphql \
|
||||||
|
-H "Authorization: $LINEAR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "{ documents(first: 25, orderBy: updatedAt) { nodes { id title slugId url updatedAt project { name } } } }"}' \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search documents by title
|
||||||
|
|
||||||
|
Linear's schema has no `searchDocuments` root. Use a title-substring filter instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.linear.app/graphql \
|
||||||
|
-H "Authorization: $LINEAR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "{ documents(filter: { title: { containsIgnoreCase: \"RFC\" } }, first: 25) { nodes { title slugId url } } }"}' \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
|
|
||||||
Linear uses Relay-style cursor pagination:
|
Linear uses Relay-style cursor pagination:
|
||||||
|
|
|
||||||
445
skills/productivity/linear/scripts/linear_api.py
Normal file
445
skills/productivity/linear/scripts/linear_api.py
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Linear GraphQL API CLI — zero dependencies, stdlib only.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
linear_api.py <command> [args...]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
whoami Show authenticated user
|
||||||
|
list-teams List all teams
|
||||||
|
list-projects [--team KEY] List projects (optionally filter by team)
|
||||||
|
list-states [--team KEY] List workflow states
|
||||||
|
list-issues [filters] List issues
|
||||||
|
--team KEY Filter by team key (e.g. ENG)
|
||||||
|
--status NAME Filter by workflow state name
|
||||||
|
--assignee NAME Filter by assignee name (exact)
|
||||||
|
--label NAME Filter by label name
|
||||||
|
--limit N Max results (default: 25)
|
||||||
|
get-issue <IDENTIFIER> Full issue details (e.g. ENG-42)
|
||||||
|
search-issues <query> Full-text search across issues
|
||||||
|
create-issue [options] Create a new issue
|
||||||
|
--title TITLE Required
|
||||||
|
--team KEY Required
|
||||||
|
--description DESC
|
||||||
|
--priority 0-4 0=none, 1=urgent, 4=low
|
||||||
|
--label NAME
|
||||||
|
--assignee NAME
|
||||||
|
--parent IDENTIFIER Parent issue ID for sub-issues
|
||||||
|
update-issue <IDENTIFIER> [options] Update existing issue (same options as create)
|
||||||
|
update-status <IDENTIFIER> <STATE> Move issue to workflow state (by state name)
|
||||||
|
add-comment <IDENTIFIER> <body> Add comment to issue
|
||||||
|
|
||||||
|
list-documents [--limit N] List documents (docs, not issues)
|
||||||
|
get-document <SLUG_OR_ID> Fetch a document by slugId (from URL) or UUID
|
||||||
|
search-documents <query> Search documents by title
|
||||||
|
|
||||||
|
raw <graphql_query> [variables_json] Run an arbitrary GraphQL query
|
||||||
|
Use --vars '{"key":"value"}' for variables
|
||||||
|
|
||||||
|
Auth:
|
||||||
|
Set LINEAR_API_KEY environment variable (from Linear Settings -> API).
|
||||||
|
Uses the personal API key header format: `Authorization: <KEY>` (no Bearer prefix).
|
||||||
|
|
||||||
|
Output:
|
||||||
|
JSON to stdout. Errors to stderr with non-zero exit code.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
API_URL = "https://api.linear.app/graphql"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_key() -> str:
|
||||||
|
key = os.environ.get("LINEAR_API_KEY", "").strip()
|
||||||
|
if not key:
|
||||||
|
sys.stderr.write(
|
||||||
|
"ERROR: LINEAR_API_KEY not set.\n"
|
||||||
|
"Create one at https://linear.app/settings/api and export it,\n"
|
||||||
|
"or add `LINEAR_API_KEY=lin_api_...` to ~/.hermes/.env\n"
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def gql(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
"""Execute a GraphQL query against Linear. Raises on HTTP error or GraphQL errors."""
|
||||||
|
key = _get_key()
|
||||||
|
payload = {"query": query}
|
||||||
|
if variables:
|
||||||
|
payload["variables"] = variables
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
API_URL,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": key, # Personal API key — NO `Bearer` prefix
|
||||||
|
"User-Agent": "hermes-agent-linear-skill/1.0",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.stderr.write(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
sys.stderr.write(f"Network error: {e}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
result = json.loads(body)
|
||||||
|
if "errors" in result and result["errors"]:
|
||||||
|
sys.stderr.write(f"GraphQL errors: {json.dumps(result['errors'], indent=2)}\n")
|
||||||
|
# Still return data if partial success; let caller decide
|
||||||
|
if not result.get("data"):
|
||||||
|
sys.exit(1)
|
||||||
|
return result.get("data", {}) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def emit(obj: Any) -> None:
|
||||||
|
print(json.dumps(obj, indent=2, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Commands ----------
|
||||||
|
|
||||||
|
def cmd_whoami(_args: argparse.Namespace) -> None:
|
||||||
|
q = "query { viewer { id name email displayName } }"
|
||||||
|
emit(gql(q).get("viewer"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_teams(_args: argparse.Namespace) -> None:
|
||||||
|
q = "query { teams(first: 100) { nodes { id key name description } } }"
|
||||||
|
emit(gql(q).get("teams", {}).get("nodes", []))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_team_id(key_or_name: str) -> str | None:
|
||||||
|
"""Map a team key (ENG) or name to UUID."""
|
||||||
|
q = "query { teams(first: 100) { nodes { id key name } } }"
|
||||||
|
teams = gql(q).get("teams", {}).get("nodes", [])
|
||||||
|
kl = key_or_name.lower()
|
||||||
|
for t in teams:
|
||||||
|
if t["key"].lower() == kl or t["name"].lower() == kl:
|
||||||
|
return t["id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_projects(args: argparse.Namespace) -> None:
|
||||||
|
if args.team:
|
||||||
|
tid = _resolve_team_id(args.team)
|
||||||
|
if not tid:
|
||||||
|
sys.stderr.write(f"Team not found: {args.team}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
q = """query($id: String!) {
|
||||||
|
team(id: $id) { projects(first: 100) { nodes { id name description state } } }
|
||||||
|
}"""
|
||||||
|
data = gql(q, {"id": tid})
|
||||||
|
emit(data.get("team", {}).get("projects", {}).get("nodes", []))
|
||||||
|
else:
|
||||||
|
q = "query { projects(first: 100) { nodes { id name description state } } }"
|
||||||
|
emit(gql(q).get("projects", {}).get("nodes", []))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_states(args: argparse.Namespace) -> None:
|
||||||
|
if args.team:
|
||||||
|
tid = _resolve_team_id(args.team)
|
||||||
|
if not tid:
|
||||||
|
sys.stderr.write(f"Team not found: {args.team}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
q = """query($id: String!) {
|
||||||
|
team(id: $id) { states(first: 100) { nodes { id name type color } } }
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"id": tid}).get("team", {}).get("states", {}).get("nodes", []))
|
||||||
|
else:
|
||||||
|
q = "query { workflowStates(first: 200) { nodes { id name type team { key } } } }"
|
||||||
|
emit(gql(q).get("workflowStates", {}).get("nodes", []))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_issues(args: argparse.Namespace) -> None:
|
||||||
|
filt: dict[str, Any] = {}
|
||||||
|
if args.team:
|
||||||
|
filt["team"] = {"key": {"eq": args.team}}
|
||||||
|
if args.status:
|
||||||
|
filt["state"] = {"name": {"eq": args.status}}
|
||||||
|
if args.assignee:
|
||||||
|
filt["assignee"] = {"name": {"eq": args.assignee}}
|
||||||
|
if args.label:
|
||||||
|
filt["labels"] = {"name": {"eq": args.label}}
|
||||||
|
|
||||||
|
q = """query($filter: IssueFilter, $first: Int!) {
|
||||||
|
issues(filter: $filter, first: $first, orderBy: updatedAt) {
|
||||||
|
nodes {
|
||||||
|
id identifier title
|
||||||
|
state { name } priority
|
||||||
|
assignee { name }
|
||||||
|
team { key }
|
||||||
|
updatedAt url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
data = gql(q, {"filter": filt or None, "first": args.limit})
|
||||||
|
emit(data.get("issues", {}).get("nodes", []))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_get_issue(args: argparse.Namespace) -> None:
|
||||||
|
q = """query($id: String!) {
|
||||||
|
issue(id: $id) {
|
||||||
|
id identifier title description
|
||||||
|
state { name type }
|
||||||
|
priority priorityLabel
|
||||||
|
assignee { name email }
|
||||||
|
creator { name }
|
||||||
|
team { key name }
|
||||||
|
project { name }
|
||||||
|
labels { nodes { name } }
|
||||||
|
parent { identifier title }
|
||||||
|
children { nodes { identifier title state { name } } }
|
||||||
|
comments { nodes { user { name } body createdAt } }
|
||||||
|
createdAt updatedAt url
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"id": args.identifier}).get("issue"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_search_issues(args: argparse.Namespace) -> None:
|
||||||
|
q = """query($term: String!, $first: Int!) {
|
||||||
|
searchIssues(term: $term, first: $first) {
|
||||||
|
nodes { id identifier title state { name } url }
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"term": args.query, "first": args.limit}).get("searchIssues", {}).get("nodes", []))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create_issue(args: argparse.Namespace) -> None:
|
||||||
|
tid = _resolve_team_id(args.team)
|
||||||
|
if not tid:
|
||||||
|
sys.stderr.write(f"Team not found: {args.team}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
inp: dict[str, Any] = {"title": args.title, "teamId": tid}
|
||||||
|
if args.description:
|
||||||
|
inp["description"] = args.description
|
||||||
|
if args.priority is not None:
|
||||||
|
inp["priority"] = args.priority
|
||||||
|
if args.parent:
|
||||||
|
inp["parentId"] = args.parent
|
||||||
|
# TODO: label + assignee name->id lookup (omitted for v1 brevity)
|
||||||
|
|
||||||
|
q = """mutation($input: IssueCreateInput!) {
|
||||||
|
issueCreate(input: $input) {
|
||||||
|
success issue { id identifier title url }
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"input": inp}).get("issueCreate"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_update_issue(args: argparse.Namespace) -> None:
|
||||||
|
inp: dict[str, Any] = {}
|
||||||
|
if args.title:
|
||||||
|
inp["title"] = args.title
|
||||||
|
if args.description:
|
||||||
|
inp["description"] = args.description
|
||||||
|
if args.priority is not None:
|
||||||
|
inp["priority"] = args.priority
|
||||||
|
if not inp:
|
||||||
|
sys.stderr.write("No update fields provided.\n")
|
||||||
|
sys.exit(1)
|
||||||
|
q = """mutation($id: String!, $input: IssueUpdateInput!) {
|
||||||
|
issueUpdate(id: $id, input: $input) {
|
||||||
|
success issue { identifier title url }
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"id": args.identifier, "input": inp}).get("issueUpdate"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_update_status(args: argparse.Namespace) -> None:
|
||||||
|
# Resolve state name -> id within the issue's team
|
||||||
|
get_q = """query($id: String!) {
|
||||||
|
issue(id: $id) { team { id states(first: 100) { nodes { id name } } } }
|
||||||
|
}"""
|
||||||
|
issue = gql(get_q, {"id": args.identifier}).get("issue")
|
||||||
|
if not issue:
|
||||||
|
sys.stderr.write(f"Issue not found: {args.identifier}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
sl = args.state.lower()
|
||||||
|
match = next((s for s in issue["team"]["states"]["nodes"] if s["name"].lower() == sl), None)
|
||||||
|
if not match:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"State '{args.state}' not found. Available: "
|
||||||
|
f"{[s['name'] for s in issue['team']['states']['nodes']]}\n"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
q = """mutation($id: String!, $stateId: String!) {
|
||||||
|
issueUpdate(id: $id, input: { stateId: $stateId }) {
|
||||||
|
success issue { identifier state { name } url }
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"id": args.identifier, "stateId": match["id"]}).get("issueUpdate"))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_add_comment(args: argparse.Namespace) -> None:
|
||||||
|
q = """mutation($input: CommentCreateInput!) {
|
||||||
|
commentCreate(input: $input) {
|
||||||
|
success comment { id body createdAt }
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"input": {"issueId": args.identifier, "body": args.body}}).get("commentCreate"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Documents ----
|
||||||
|
|
||||||
|
def cmd_list_documents(args: argparse.Namespace) -> None:
|
||||||
|
q = """query($first: Int!) {
|
||||||
|
documents(first: $first, orderBy: updatedAt) {
|
||||||
|
nodes { id title slugId updatedAt url project { name } creator { name } }
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"first": args.limit}).get("documents", {}).get("nodes", []))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_get_document(args: argparse.Namespace) -> None:
|
||||||
|
"""Fetch a document by slugId (from URL) OR full UUID.
|
||||||
|
|
||||||
|
Linear document URLs look like:
|
||||||
|
https://linear.app/<workspace>/document/<slug>-<shortid>
|
||||||
|
The part we want is the final hex segment (the slugId).
|
||||||
|
"""
|
||||||
|
ref = args.ref
|
||||||
|
# If it looks like a UUID, query by id. Otherwise, assume slugId.
|
||||||
|
is_uuid = len(ref) == 36 and ref.count("-") == 4
|
||||||
|
if is_uuid:
|
||||||
|
q = """query($id: String!) {
|
||||||
|
document(id: $id) {
|
||||||
|
id title content contentState slugId
|
||||||
|
createdAt updatedAt url
|
||||||
|
creator { name } project { name }
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"id": ref}).get("document"))
|
||||||
|
else:
|
||||||
|
# Query the collection and filter by slugId — the doc() query only accepts UUIDs.
|
||||||
|
q = """query($slug: String!) {
|
||||||
|
documents(filter: { slugId: { eq: $slug } }, first: 1) {
|
||||||
|
nodes {
|
||||||
|
id title content contentState slugId
|
||||||
|
createdAt updatedAt url
|
||||||
|
creator { name } project { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
nodes = gql(q, {"slug": ref}).get("documents", {}).get("nodes", [])
|
||||||
|
emit(nodes[0] if nodes else None)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_search_documents(args: argparse.Namespace) -> None:
|
||||||
|
# Linear doesn't have a first-class searchDocuments — use title filter as a fallback.
|
||||||
|
q = """query($term: String!, $first: Int!) {
|
||||||
|
documents(filter: { title: { containsIgnoreCase: $term } }, first: $first) {
|
||||||
|
nodes { id title slugId url updatedAt }
|
||||||
|
}
|
||||||
|
}"""
|
||||||
|
emit(gql(q, {"term": args.query, "first": args.limit}).get("documents", {}).get("nodes", []))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_raw(args: argparse.Namespace) -> None:
|
||||||
|
variables = json.loads(args.vars) if args.vars else None
|
||||||
|
emit(gql(args.query, variables))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Arg parsing ----------
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="linear_api.py", description="Linear GraphQL CLI")
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("whoami").set_defaults(func=cmd_whoami)
|
||||||
|
sub.add_parser("list-teams").set_defaults(func=cmd_list_teams)
|
||||||
|
|
||||||
|
lp = sub.add_parser("list-projects")
|
||||||
|
lp.add_argument("--team")
|
||||||
|
lp.set_defaults(func=cmd_list_projects)
|
||||||
|
|
||||||
|
ls = sub.add_parser("list-states")
|
||||||
|
ls.add_argument("--team")
|
||||||
|
ls.set_defaults(func=cmd_list_states)
|
||||||
|
|
||||||
|
li = sub.add_parser("list-issues")
|
||||||
|
li.add_argument("--team")
|
||||||
|
li.add_argument("--status")
|
||||||
|
li.add_argument("--assignee")
|
||||||
|
li.add_argument("--label")
|
||||||
|
li.add_argument("--limit", type=int, default=25)
|
||||||
|
li.set_defaults(func=cmd_list_issues)
|
||||||
|
|
||||||
|
gi = sub.add_parser("get-issue")
|
||||||
|
gi.add_argument("identifier")
|
||||||
|
gi.set_defaults(func=cmd_get_issue)
|
||||||
|
|
||||||
|
si = sub.add_parser("search-issues")
|
||||||
|
si.add_argument("query")
|
||||||
|
si.add_argument("--limit", type=int, default=25)
|
||||||
|
si.set_defaults(func=cmd_search_issues)
|
||||||
|
|
||||||
|
ci = sub.add_parser("create-issue")
|
||||||
|
ci.add_argument("--title", required=True)
|
||||||
|
ci.add_argument("--team", required=True)
|
||||||
|
ci.add_argument("--description")
|
||||||
|
ci.add_argument("--priority", type=int, choices=[0, 1, 2, 3, 4])
|
||||||
|
ci.add_argument("--label")
|
||||||
|
ci.add_argument("--assignee")
|
||||||
|
ci.add_argument("--parent")
|
||||||
|
ci.set_defaults(func=cmd_create_issue)
|
||||||
|
|
||||||
|
ui = sub.add_parser("update-issue")
|
||||||
|
ui.add_argument("identifier")
|
||||||
|
ui.add_argument("--title")
|
||||||
|
ui.add_argument("--description")
|
||||||
|
ui.add_argument("--priority", type=int, choices=[0, 1, 2, 3, 4])
|
||||||
|
ui.set_defaults(func=cmd_update_issue)
|
||||||
|
|
||||||
|
us = sub.add_parser("update-status")
|
||||||
|
us.add_argument("identifier")
|
||||||
|
us.add_argument("state")
|
||||||
|
us.set_defaults(func=cmd_update_status)
|
||||||
|
|
||||||
|
ac = sub.add_parser("add-comment")
|
||||||
|
ac.add_argument("identifier")
|
||||||
|
ac.add_argument("body")
|
||||||
|
ac.set_defaults(func=cmd_add_comment)
|
||||||
|
|
||||||
|
ld = sub.add_parser("list-documents")
|
||||||
|
ld.add_argument("--limit", type=int, default=50)
|
||||||
|
ld.set_defaults(func=cmd_list_documents)
|
||||||
|
|
||||||
|
gd = sub.add_parser("get-document")
|
||||||
|
gd.add_argument("ref", help="slugId (hex suffix from URL) or full UUID")
|
||||||
|
gd.set_defaults(func=cmd_get_document)
|
||||||
|
|
||||||
|
sd = sub.add_parser("search-documents")
|
||||||
|
sd.add_argument("query")
|
||||||
|
sd.add_argument("--limit", type=int, default=25)
|
||||||
|
sd.set_defaults(func=cmd_search_documents)
|
||||||
|
|
||||||
|
r = sub.add_parser("raw")
|
||||||
|
r.add_argument("query")
|
||||||
|
r.add_argument("--vars", help="JSON string of variables")
|
||||||
|
r.set_defaults(func=cmd_raw)
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -33,7 +33,7 @@ Manage Linear issues, projects, and teams directly via the GraphQL API using `cu
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Get a personal API key from **Linear Settings > API > Personal API keys**
|
1. Get a personal API key from **Linear Settings > Account > Security & access > Personal API keys** (URL: https://linear.app/settings/account/security). Note: the org-level *Settings > API* page only shows OAuth apps and workspace-member keys, not personal keys.
|
||||||
2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config)
|
2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config)
|
||||||
|
|
||||||
## API Basics
|
## API Basics
|
||||||
|
|
@ -51,6 +51,24 @@ curl -s -X POST https://api.linear.app/graphql \
|
||||||
-d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool
|
-d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Python helper script (ergonomic alternative)
|
||||||
|
|
||||||
|
For faster one-liners that don't need hand-written GraphQL, this skill ships a stdlib Python CLI at `scripts/linear_api.py`. Zero dependencies. Same auth (reads `LINEAR_API_KEY`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCRIPT=$(dirname "$(find ~/.hermes -path '*skills/productivity/linear/scripts/linear_api.py' 2>/dev/null | head -1)")/linear_api.py
|
||||||
|
|
||||||
|
python3 "$SCRIPT" whoami
|
||||||
|
python3 "$SCRIPT" list-teams
|
||||||
|
python3 "$SCRIPT" get-issue ENG-42
|
||||||
|
python3 "$SCRIPT" get-document 38359beef67c # fetch a doc by slugId from the URL
|
||||||
|
python3 "$SCRIPT" raw 'query { viewer { name } }'
|
||||||
|
```
|
||||||
|
|
||||||
|
All subcommands: `whoami`, `list-teams`, `list-projects`, `list-states`, `list-issues`, `get-issue`, `search-issues`, `create-issue`, `update-issue`, `update-status`, `add-comment`, `list-documents`, `get-document`, `search-documents`, `raw`. Run with `--help` for flags.
|
||||||
|
|
||||||
|
Use the script when: you want a quick answer without crafting GraphQL. Use curl when: you need a query the script doesn't wrap, or you want to compose filters inline.
|
||||||
|
|
||||||
## Workflow States
|
## Workflow States
|
||||||
|
|
||||||
Linear uses `WorkflowState` objects with a `type` field. **6 state types:**
|
Linear uses `WorkflowState` objects with a `type` field. **6 state types:**
|
||||||
|
|
@ -260,6 +278,70 @@ curl -s -X POST https://api.linear.app/graphql \
|
||||||
}' | python3 -m json.tool
|
}' | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
Linear **Documents** are prose docs (RFCs, specs, notes) stored alongside issues. They have their own `documents` root query and `document(id:)` single-fetch.
|
||||||
|
|
||||||
|
### Document URLs and `slugId`
|
||||||
|
|
||||||
|
Document URLs look like:
|
||||||
|
```
|
||||||
|
https://linear.app/<workspace>/document/<slug>-<hexSlugId>
|
||||||
|
```
|
||||||
|
|
||||||
|
The trailing hex segment is the `slugId`. Example: `https://linear.app/nousresearch/document/rfc-hermes-permission-gateway-discord-38359beef67c` → `slugId` is `38359beef67c`.
|
||||||
|
|
||||||
|
**Important schema detail:** the Markdown body is in the `content` field. The ProseMirror JSON is in `contentState` (not `contentData` — that field does not exist and the API returns 400).
|
||||||
|
|
||||||
|
### Fetch a document by slugId
|
||||||
|
|
||||||
|
`document(id:)` only accepts UUIDs. To fetch by the URL's hex slug, filter the collection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.linear.app/graphql \
|
||||||
|
-H "Authorization: $LINEAR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "query($s: String!) { documents(filter: { slugId: { eq: $s } }, first: 1) { nodes { id title content contentState slugId url creator { name } project { name } updatedAt } } }", "variables": {"s": "38359beef67c"}}' \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via the Python helper:
|
||||||
|
```bash
|
||||||
|
python3 scripts/linear_api.py get-document 38359beef67c
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch a document by UUID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.linear.app/graphql \
|
||||||
|
-H "Authorization: $LINEAR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "{ document(id: \"11700cff-b514-4db3-afcc-3ed1afacba1c\") { title content url } }"}' \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### List recent documents
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.linear.app/graphql \
|
||||||
|
-H "Authorization: $LINEAR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "{ documents(first: 25, orderBy: updatedAt) { nodes { id title slugId url updatedAt project { name } } } }"}' \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search documents by title
|
||||||
|
|
||||||
|
Linear's schema has no `searchDocuments` root. Use a title-substring filter instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST https://api.linear.app/graphql \
|
||||||
|
-H "Authorization: $LINEAR_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "{ documents(filter: { title: { containsIgnoreCase: \"RFC\" } }, first: 25) { nodes { title slugId url } } }"}' \
|
||||||
|
| python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
## Pagination
|
## Pagination
|
||||||
|
|
||||||
Linear uses Relay-style cursor pagination:
|
Linear uses Relay-style cursor pagination:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue